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
|
||||
]
|
||||
|
||||
@@ -4,7 +4,8 @@ sqlalchemy==2.0.36
|
||||
alembic==1.14.0
|
||||
psycopg2-binary==2.9.10
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
passlib==1.7.4
|
||||
bcrypt==4.0.1
|
||||
python-multipart==0.0.20
|
||||
pydantic[email]==2.10.4
|
||||
pydantic-settings==2.7.1
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"lint": "eslint src/**/*.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@whiskeysockets/baileys": "^6.7.16",
|
||||
"@whiskeysockets/baileys": "^6.7.17",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
"socket.io": "^4.8.1",
|
||||
"ioredis": "^5.4.1",
|
||||
@@ -19,6 +20,7 @@
|
||||
"uuid": "^11.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/uuid": "^10.0.0",
|
||||
|
||||
@@ -50,6 +50,28 @@ export function createRouter(sessionManager: SessionManager): Router {
|
||||
}
|
||||
});
|
||||
|
||||
// Pause session (disconnect without logout)
|
||||
router.post('/sessions/:accountId/pause', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const accountId = req.params.accountId as string;
|
||||
await sessionManager.pauseSession(accountId);
|
||||
res.json({ success: true, status: 'paused' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// Resume session
|
||||
router.post('/sessions/:accountId/resume', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const accountId = req.params.accountId as string;
|
||||
const session = await sessionManager.resumeSession(accountId);
|
||||
res.json({ success: true, session });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete session
|
||||
router.delete('/sessions/:accountId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import { createServer } from 'http';
|
||||
import { Server as SocketIOServer } from 'socket.io';
|
||||
import { SessionManager } from './sessions/SessionManager';
|
||||
@@ -21,8 +23,12 @@ async function main() {
|
||||
path: '/ws',
|
||||
});
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Serve media files statically
|
||||
app.use('/media', express.static(path.join(process.cwd(), 'media')));
|
||||
|
||||
const sessionManager = new SessionManager('./sessions');
|
||||
const router = createRouter(sessionManager);
|
||||
app.use('/api', router);
|
||||
@@ -36,11 +42,14 @@ async function main() {
|
||||
|
||||
// Forward to API Gateway
|
||||
try {
|
||||
await fetch(`${API_GATEWAY_URL}/api/internal/whatsapp/event`, {
|
||||
const response = await fetch(`${API_GATEWAY_URL}/api/whatsapp/internal/whatsapp/event`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(event),
|
||||
});
|
||||
if (!response.ok) {
|
||||
logger.error({ status: response.status }, 'API Gateway rejected event');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to forward event to API Gateway');
|
||||
}
|
||||
|
||||
319
services/whatsapp-core/src/sessions/SessionManager.ts
Normal file
319
services/whatsapp-core/src/sessions/SessionManager.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import makeWASocket, {
|
||||
DisconnectReason,
|
||||
useMultiFileAuthState,
|
||||
WASocket,
|
||||
proto,
|
||||
fetchLatestBaileysVersion,
|
||||
makeCacheableSignalKeyStore,
|
||||
downloadMediaMessage,
|
||||
getContentType,
|
||||
} from '@whiskeysockets/baileys';
|
||||
import { Boom } from '@hapi/boom';
|
||||
import * as QRCode from 'qrcode';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import pino from 'pino';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { SessionStore, SessionInfo, SessionEvent } from './types';
|
||||
|
||||
const logger = pino({ level: 'info' });
|
||||
|
||||
export class SessionManager extends EventEmitter {
|
||||
private sessions: Map<string, SessionStore> = new Map();
|
||||
private sessionsPath: string;
|
||||
private mediaPath: string;
|
||||
|
||||
constructor(sessionsPath: string = './sessions') {
|
||||
super();
|
||||
this.sessionsPath = sessionsPath;
|
||||
this.mediaPath = './media';
|
||||
if (!fs.existsSync(sessionsPath)) {
|
||||
fs.mkdirSync(sessionsPath, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(this.mediaPath)) {
|
||||
fs.mkdirSync(this.mediaPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async createSession(accountId: string, name: string, phoneNumber?: string): Promise<SessionInfo> {
|
||||
if (this.sessions.has(accountId)) {
|
||||
const existing = this.sessions.get(accountId)!;
|
||||
return existing.info;
|
||||
}
|
||||
|
||||
const sessionPath = path.join(this.sessionsPath, accountId);
|
||||
const { state, saveCreds } = await useMultiFileAuthState(sessionPath);
|
||||
|
||||
const info: SessionInfo = {
|
||||
accountId,
|
||||
phoneNumber: null,
|
||||
name,
|
||||
status: 'connecting',
|
||||
qrCode: null,
|
||||
pairingCode: null,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const store: SessionStore = { socket: null, info };
|
||||
this.sessions.set(accountId, store);
|
||||
|
||||
// Get latest version
|
||||
const { version } = await fetchLatestBaileysVersion();
|
||||
logger.info({ version }, 'Using WA version');
|
||||
|
||||
const socket = makeWASocket({
|
||||
version,
|
||||
auth: {
|
||||
creds: state.creds,
|
||||
keys: makeCacheableSignalKeyStore(state.keys, logger),
|
||||
},
|
||||
logger: pino({ level: 'warn' }),
|
||||
syncFullHistory: false,
|
||||
markOnlineOnConnect: false,
|
||||
browser: ['WhatsApp Central', 'Desktop', '1.0.0'],
|
||||
connectTimeoutMs: 60000,
|
||||
retryRequestDelayMs: 2000,
|
||||
});
|
||||
|
||||
store.socket = socket;
|
||||
|
||||
socket.ev.on('creds.update', saveCreds);
|
||||
|
||||
socket.ev.on('connection.update', async (update) => {
|
||||
const { connection, lastDisconnect, qr } = update;
|
||||
|
||||
logger.info({ connection, hasQR: !!qr }, 'Connection update');
|
||||
|
||||
if (qr) {
|
||||
try {
|
||||
const qrDataUrl = await QRCode.toDataURL(qr);
|
||||
info.qrCode = qrDataUrl;
|
||||
info.status = 'connecting';
|
||||
logger.info({ accountId }, 'QR code generated');
|
||||
this.emitEvent({ type: 'qr', accountId, data: { qrCode: qrDataUrl } });
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to generate QR');
|
||||
}
|
||||
}
|
||||
|
||||
if (connection === 'close') {
|
||||
const reason = (lastDisconnect?.error as Boom)?.output?.statusCode;
|
||||
const shouldReconnect = reason !== DisconnectReason.loggedOut;
|
||||
|
||||
logger.info({ reason, shouldReconnect }, `Connection closed for ${accountId}`);
|
||||
|
||||
info.status = 'disconnected';
|
||||
info.qrCode = null;
|
||||
|
||||
if (shouldReconnect) {
|
||||
// Delete session data and try fresh
|
||||
this.sessions.delete(accountId);
|
||||
setTimeout(() => {
|
||||
logger.info(`Retrying session ${accountId}...`);
|
||||
this.createSession(accountId, name, phoneNumber);
|
||||
}, 5000);
|
||||
} else {
|
||||
logger.info(`Session ${accountId} logged out`);
|
||||
this.emitEvent({ type: 'disconnected', accountId, data: { reason: 'logged_out' } });
|
||||
}
|
||||
}
|
||||
|
||||
if (connection === 'open') {
|
||||
info.status = 'connected';
|
||||
info.qrCode = null;
|
||||
info.phoneNumber = socket.user?.id?.split(':')[0] || null;
|
||||
logger.info(`Session ${accountId} connected: ${info.phoneNumber}`);
|
||||
this.emitEvent({ type: 'connected', accountId, data: { phoneNumber: info.phoneNumber } });
|
||||
}
|
||||
});
|
||||
|
||||
// Request pairing code if phone number provided
|
||||
if (phoneNumber && !state.creds.registered) {
|
||||
try {
|
||||
const code = await socket.requestPairingCode(phoneNumber);
|
||||
info.pairingCode = code;
|
||||
logger.info({ accountId, code }, 'Pairing code generated');
|
||||
this.emitEvent({ type: 'pairing_code', accountId, data: { code } });
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to request pairing code');
|
||||
}
|
||||
}
|
||||
|
||||
socket.ev.on('messages.upsert', async ({ messages, type }) => {
|
||||
if (type !== 'notify') return;
|
||||
|
||||
for (const msg of messages) {
|
||||
const remoteJid = msg.key.remoteJid || '';
|
||||
|
||||
// Skip group messages (groups end with @g.us)
|
||||
if (remoteJid.endsWith('@g.us')) continue;
|
||||
|
||||
// Skip broadcast lists
|
||||
if (remoteJid === 'status@broadcast') continue;
|
||||
|
||||
// Skip non-user messages (like system messages)
|
||||
if (!remoteJid.endsWith('@s.whatsapp.net')) continue;
|
||||
|
||||
// Detect message type and handle media
|
||||
let mediaUrl: string | null = null;
|
||||
let mediaType: string = 'text';
|
||||
const msgContent = msg.message;
|
||||
|
||||
if (msgContent) {
|
||||
const contentType = getContentType(msgContent);
|
||||
|
||||
if (contentType && ['imageMessage', 'audioMessage', 'videoMessage', 'documentMessage', 'stickerMessage'].includes(contentType)) {
|
||||
try {
|
||||
// Download media
|
||||
const buffer = await downloadMediaMessage(
|
||||
msg,
|
||||
'buffer',
|
||||
{},
|
||||
{
|
||||
logger,
|
||||
reuploadRequest: socket.updateMediaMessage,
|
||||
}
|
||||
);
|
||||
|
||||
if (buffer) {
|
||||
// Determine file extension
|
||||
const extensions: Record<string, string> = {
|
||||
imageMessage: 'jpg',
|
||||
audioMessage: 'ogg',
|
||||
videoMessage: 'mp4',
|
||||
documentMessage: 'pdf',
|
||||
stickerMessage: 'webp',
|
||||
};
|
||||
const ext = extensions[contentType] || 'bin';
|
||||
const filename = `${randomUUID()}.${ext}`;
|
||||
const filepath = path.join(this.mediaPath, filename);
|
||||
|
||||
// Save file
|
||||
fs.writeFileSync(filepath, buffer as Buffer);
|
||||
mediaUrl = `/media/${filename}`;
|
||||
mediaType = contentType.replace('Message', '');
|
||||
|
||||
logger.info({ accountId, filename, mediaType }, 'Media downloaded');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to download media');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const messageData = {
|
||||
id: msg.key.id,
|
||||
from: remoteJid,
|
||||
fromMe: msg.key.fromMe || false,
|
||||
pushName: msg.pushName,
|
||||
message: msg.message,
|
||||
timestamp: msg.messageTimestamp,
|
||||
mediaUrl,
|
||||
mediaType,
|
||||
};
|
||||
|
||||
this.emitEvent({ type: 'message', accountId, data: messageData });
|
||||
}
|
||||
});
|
||||
|
||||
socket.ev.on('messages.update', (updates) => {
|
||||
for (const update of updates) {
|
||||
if (update.update.status) {
|
||||
this.emitEvent({
|
||||
type: 'message_status',
|
||||
accountId,
|
||||
data: {
|
||||
id: update.key.id,
|
||||
remoteJid: update.key.remoteJid,
|
||||
status: update.update.status,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
async disconnectSession(accountId: string): Promise<void> {
|
||||
const store = this.sessions.get(accountId);
|
||||
if (!store || !store.socket) return;
|
||||
|
||||
await store.socket.logout();
|
||||
store.socket = null;
|
||||
store.info.status = 'disconnected';
|
||||
store.info.qrCode = null;
|
||||
}
|
||||
|
||||
async pauseSession(accountId: string): Promise<void> {
|
||||
const store = this.sessions.get(accountId);
|
||||
if (!store || !store.socket) return;
|
||||
|
||||
// Close connection without logout (keeps credentials)
|
||||
store.socket.end(undefined);
|
||||
store.socket = null;
|
||||
store.info.status = 'paused';
|
||||
store.info.qrCode = null;
|
||||
|
||||
logger.info({ accountId }, 'Session paused');
|
||||
this.emitEvent({ type: 'paused', accountId, data: {} });
|
||||
}
|
||||
|
||||
async resumeSession(accountId: string): Promise<SessionInfo> {
|
||||
const store = this.sessions.get(accountId);
|
||||
if (!store) {
|
||||
throw new Error(`Session ${accountId} not found`);
|
||||
}
|
||||
|
||||
if (store.info.status === 'connected') {
|
||||
return store.info;
|
||||
}
|
||||
|
||||
logger.info({ accountId }, 'Resuming session');
|
||||
|
||||
// Recreate the session using existing credentials
|
||||
const name = store.info.name;
|
||||
this.sessions.delete(accountId);
|
||||
|
||||
return this.createSession(accountId, name);
|
||||
}
|
||||
|
||||
async deleteSession(accountId: string): Promise<void> {
|
||||
await this.disconnectSession(accountId);
|
||||
this.sessions.delete(accountId);
|
||||
|
||||
const sessionPath = path.join(this.sessionsPath, accountId);
|
||||
if (fs.existsSync(sessionPath)) {
|
||||
fs.rmSync(sessionPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
getSession(accountId: string): SessionInfo | null {
|
||||
const store = this.sessions.get(accountId);
|
||||
return store?.info || null;
|
||||
}
|
||||
|
||||
getAllSessions(): SessionInfo[] {
|
||||
return Array.from(this.sessions.values()).map((s) => s.info);
|
||||
}
|
||||
|
||||
async sendMessage(
|
||||
accountId: string,
|
||||
to: string,
|
||||
content: proto.IMessage
|
||||
): Promise<proto.WebMessageInfo | null> {
|
||||
const store = this.sessions.get(accountId);
|
||||
if (!store?.socket || store.info.status !== 'connected') {
|
||||
throw new Error(`Session ${accountId} not connected`);
|
||||
}
|
||||
|
||||
const jid = to.includes('@') ? to : `${to}@s.whatsapp.net`;
|
||||
const result = await store.socket.sendMessage(jid, content as any);
|
||||
return result || null;
|
||||
}
|
||||
|
||||
private emitEvent(event: SessionEvent): void {
|
||||
this.emit('session_event', event);
|
||||
}
|
||||
}
|
||||
31
services/whatsapp-core/src/sessions/types.ts
Normal file
31
services/whatsapp-core/src/sessions/types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { WASocket } from '@whiskeysockets/baileys';
|
||||
|
||||
export interface SessionInfo {
|
||||
accountId: string;
|
||||
phoneNumber: string | null;
|
||||
name: string;
|
||||
status: 'connecting' | 'connected' | 'disconnected' | 'paused';
|
||||
qrCode: string | null;
|
||||
pairingCode: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface SessionStore {
|
||||
socket: WASocket | null;
|
||||
info: SessionInfo;
|
||||
}
|
||||
|
||||
export type SessionEventType =
|
||||
| 'qr'
|
||||
| 'pairing_code'
|
||||
| 'connected'
|
||||
| 'disconnected'
|
||||
| 'paused'
|
||||
| 'message'
|
||||
| 'message_status';
|
||||
|
||||
export interface SessionEvent {
|
||||
type: SessionEventType;
|
||||
accountId: string;
|
||||
data: unknown;
|
||||
}
|
||||
Reference in New Issue
Block a user