feat: add Layer 2 - WhatsApp Core logic, API Gateway models/auth, Frontend core

WhatsApp Core:
- SessionManager with Baileys integration for multi-account support
- Express server with REST API and Socket.IO for real-time events
- Session lifecycle management (create, disconnect, delete)
- Message sending with support for text, image, document, audio, video

API Gateway:
- Database models: User, WhatsAppAccount, Contact, Conversation, Message
- JWT authentication with access/refresh tokens
- Auth endpoints: login, refresh, register, me
- Pydantic schemas for request/response validation

Frontend:
- React 18 app structure with routing
- Zustand auth store with persistence
- API client with automatic token handling
- Base CSS and TypeScript declarations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude AI
2026-01-29 09:55:10 +00:00
parent 31d68bc118
commit 7042aa2061
19 changed files with 827 additions and 0 deletions

33
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,33 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from './store/auth';
// Placeholder components - will be replaced
const LoginPage = () => <div>Login Page</div>;
const DashboardPage = () => <div>Dashboard</div>;
const MainLayout = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
function PrivateRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/*"
element={
<PrivateRoute>
<MainLayout>
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
</Routes>
</MainLayout>
</PrivateRoute>
}
/>
</Routes>
);
}

View File

@@ -0,0 +1,79 @@
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
interface RequestOptions extends RequestInit {
params?: Record<string, string>;
}
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
private getAuthHeaders(): Record<string, string> {
const token = localStorage.getItem('access_token');
return token ? { Authorization: `Bearer ${token}` } : {};
}
private async request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
const { params, ...fetchOptions } = options;
let url = `${this.baseUrl}${endpoint}`;
if (params) {
const searchParams = new URLSearchParams(params);
url += `?${searchParams.toString()}`;
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...this.getAuthHeaders(),
...(options.headers as Record<string, string>),
};
const response = await fetch(url, {
...fetchOptions,
headers,
});
if (response.status === 401) {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
window.location.href = '/login';
throw new Error('Unauthorized');
}
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Request failed' }));
throw new Error(error.detail || 'Request failed');
}
return response.json();
}
get<T>(endpoint: string, options?: RequestOptions): Promise<T> {
return this.request<T>(endpoint, { ...options, method: 'GET' });
}
post<T>(endpoint: string, data?: unknown, options?: RequestOptions): Promise<T> {
return this.request<T>(endpoint, {
...options,
method: 'POST',
body: JSON.stringify(data),
});
}
put<T>(endpoint: string, data?: unknown, options?: RequestOptions): Promise<T> {
return this.request<T>(endpoint, {
...options,
method: 'PUT',
body: JSON.stringify(data),
});
}
delete<T>(endpoint: string, options?: RequestOptions): Promise<T> {
return this.request<T>(endpoint, { ...options, method: 'DELETE' });
}
}
export const apiClient = new ApiClient(API_BASE_URL);

15
frontend/src/index.css Normal file
View File

@@ -0,0 +1,15 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
min-height: 100vh;
}

29
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,29 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ConfigProvider } from 'antd';
import esES from 'antd/locale/es_ES';
import App from './App';
import './index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<ConfigProvider locale={esES}>
<BrowserRouter>
<App />
</BrowserRouter>
</ConfigProvider>
</QueryClientProvider>
</React.StrictMode>
);

View File

@@ -0,0 +1,54 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface User {
id: string;
email: string;
name: string;
role: string;
status: string;
}
interface AuthState {
user: User | null;
accessToken: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
setAuth: (user: User, accessToken: string, refreshToken: string) => void;
logout: () => void;
updateUser: (user: Partial<User>) => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
setAuth: (user, accessToken, refreshToken) => {
localStorage.setItem('access_token', accessToken);
localStorage.setItem('refresh_token', refreshToken);
set({ user, accessToken, refreshToken, isAuthenticated: true });
},
logout: () => {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
set({ user: null, accessToken: null, refreshToken: null, isAuthenticated: false });
},
updateUser: (userData) =>
set((state) => ({
user: state.user ? { ...state.user, ...userData } : null,
})),
}),
{
name: 'auth-storage',
partialize: (state) => ({
user: state.user,
accessToken: state.accessToken,
refreshToken: state.refreshToken,
isAuthenticated: state.isAuthenticated,
}),
}
)
);

10
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_WS_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,30 @@
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
# Database
DATABASE_URL: str = "postgresql://whatsapp_admin:password@localhost:5432/whatsapp_central"
# Redis
REDIS_URL: str = "redis://localhost:6379"
# JWT
JWT_SECRET: str = "change-me-in-production"
JWT_ALGORITHM: str = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# WhatsApp Core
WHATSAPP_CORE_URL: str = "http://localhost:3001"
# CORS
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
class Config:
env_file = ".env"
@lru_cache
def get_settings() -> Settings:
return Settings()

View File

@@ -0,0 +1,18 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from app.core.config import get_settings
settings = get_settings()
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,77 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.database import get_db
from app.models.user import User
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
security = HTTPBearer()
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire, "type": "access"})
return jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
def create_refresh_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
return jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
def decode_token(token: str) -> dict:
try:
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
return payload
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
)
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
) -> User:
token = credentials.credentials
payload = decode_token(token)
if payload.get("type") != "access":
raise HTTPException(status_code=401, detail="Invalid token type")
user_id = payload.get("sub")
if not user_id:
raise HTTPException(status_code=401, detail="Invalid token")
user = db.query(User).filter(User.id == user_id).first()
if not user or not user.is_active:
raise HTTPException(status_code=401, detail="User not found or inactive")
return user
def require_role(*roles):
async def role_checker(current_user: User = Depends(get_current_user)):
if current_user.role not in roles:
raise HTTPException(status_code=403, detail="Insufficient permissions")
return current_user
return role_checker

View File

@@ -0,0 +1,4 @@
from app.models.user import User
from app.models.whatsapp import WhatsAppAccount, Contact, Conversation, Message
__all__ = ["User", "WhatsAppAccount", "Contact", "Conversation", "Message"]

View File

@@ -0,0 +1,33 @@
import uuid
from datetime import datetime
from sqlalchemy import Column, String, Boolean, DateTime, Enum as SQLEnum
from sqlalchemy.dialects.postgresql import UUID
import enum
from app.core.database import Base
class UserRole(str, enum.Enum):
ADMIN = "admin"
SUPERVISOR = "supervisor"
AGENT = "agent"
class UserStatus(str, enum.Enum):
ONLINE = "online"
OFFLINE = "offline"
AWAY = "away"
BUSY = "busy"
class User(Base):
__tablename__ = "users"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email = Column(String(255), unique=True, nullable=False, index=True)
password_hash = Column(String(255), nullable=False)
name = Column(String(100), nullable=False)
role = Column(SQLEnum(UserRole), default=UserRole.AGENT, nullable=False)
status = Column(SQLEnum(UserStatus), default=UserStatus.OFFLINE, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

View File

@@ -0,0 +1,111 @@
import uuid
from datetime import datetime
from sqlalchemy import Column, String, Boolean, DateTime, Text, Integer, ForeignKey, Enum as SQLEnum
from sqlalchemy.dialects.postgresql import UUID, JSONB, ARRAY
from sqlalchemy.orm import relationship
import enum
from app.core.database import Base
class AccountStatus(str, enum.Enum):
CONNECTING = "connecting"
CONNECTED = "connected"
DISCONNECTED = "disconnected"
class ConversationStatus(str, enum.Enum):
BOT = "bot"
WAITING = "waiting"
ACTIVE = "active"
RESOLVED = "resolved"
class MessageDirection(str, enum.Enum):
INBOUND = "inbound"
OUTBOUND = "outbound"
class MessageType(str, enum.Enum):
TEXT = "text"
IMAGE = "image"
AUDIO = "audio"
VIDEO = "video"
DOCUMENT = "document"
LOCATION = "location"
CONTACT = "contact"
STICKER = "sticker"
BUTTONS = "buttons"
LIST = "list"
class MessageStatus(str, enum.Enum):
PENDING = "pending"
SENT = "sent"
DELIVERED = "delivered"
READ = "read"
FAILED = "failed"
class WhatsAppAccount(Base):
__tablename__ = "whatsapp_accounts"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
phone_number = Column(String(20), nullable=True)
name = Column(String(100), nullable=False)
status = Column(SQLEnum(AccountStatus), default=AccountStatus.DISCONNECTED, nullable=False)
session_data = Column(JSONB, nullable=True)
qr_code = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
conversations = relationship("Conversation", back_populates="whatsapp_account")
class Contact(Base):
__tablename__ = "contacts"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
phone_number = Column(String(20), unique=True, nullable=False, index=True)
name = Column(String(100), nullable=True)
email = Column(String(255), nullable=True)
company = Column(String(100), nullable=True)
metadata = Column(JSONB, default=dict)
tags = Column(ARRAY(String), default=list)
odoo_partner_id = Column(Integer, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
conversations = relationship("Conversation", back_populates="contact")
class Conversation(Base):
__tablename__ = "conversations"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
whatsapp_account_id = Column(UUID(as_uuid=True), ForeignKey("whatsapp_accounts.id"), nullable=False)
contact_id = Column(UUID(as_uuid=True), ForeignKey("contacts.id"), nullable=False)
assigned_to = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
status = Column(SQLEnum(ConversationStatus), default=ConversationStatus.BOT, nullable=False)
last_message_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
whatsapp_account = relationship("WhatsAppAccount", back_populates="conversations")
contact = relationship("Contact", back_populates="conversations")
messages = relationship("Message", back_populates="conversation", order_by="Message.created_at")
class Message(Base):
__tablename__ = "messages"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
conversation_id = Column(UUID(as_uuid=True), ForeignKey("conversations.id"), nullable=False)
whatsapp_message_id = Column(String(100), nullable=True)
direction = Column(SQLEnum(MessageDirection), nullable=False)
type = Column(SQLEnum(MessageType), default=MessageType.TEXT, nullable=False)
content = Column(Text, nullable=True)
media_url = Column(String(500), nullable=True)
metadata = Column(JSONB, default=dict)
sent_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
is_internal_note = Column(Boolean, default=False, nullable=False)
status = Column(SQLEnum(MessageStatus), default=MessageStatus.PENDING, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
conversation = relationship("Conversation", back_populates="messages")

View File

@@ -0,0 +1,3 @@
from app.routers.auth import router as auth_router
__all__ = ["auth_router"]

View File

@@ -0,0 +1,96 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import (
verify_password,
get_password_hash,
create_access_token,
create_refresh_token,
decode_token,
get_current_user,
)
from app.models.user import User, UserRole
from app.schemas.auth import (
LoginRequest,
LoginResponse,
RefreshRequest,
TokenResponse,
UserResponse,
CreateUserRequest,
)
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=LoginResponse)
def login(request: LoginRequest, db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == request.email).first()
if not user or not verify_password(request.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User account is disabled",
)
access_token = create_access_token(data={"sub": str(user.id)})
refresh_token = create_refresh_token(data={"sub": str(user.id)})
return LoginResponse(
access_token=access_token,
refresh_token=refresh_token,
user=UserResponse.model_validate(user),
)
@router.post("/refresh", response_model=TokenResponse)
def refresh_token(request: RefreshRequest, db: Session = Depends(get_db)):
payload = decode_token(request.refresh_token)
if payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Invalid token type")
user_id = payload.get("sub")
user = db.query(User).filter(User.id == user_id).first()
if not user or not user.is_active:
raise HTTPException(status_code=401, detail="User not found")
access_token = create_access_token(data={"sub": str(user.id)})
new_refresh_token = create_refresh_token(data={"sub": str(user.id)})
return TokenResponse(access_token=access_token, refresh_token=new_refresh_token)
@router.get("/me", response_model=UserResponse)
def get_me(current_user: User = Depends(get_current_user)):
return UserResponse.model_validate(current_user)
@router.post("/register", response_model=UserResponse)
def register_first_admin(request: CreateUserRequest, db: Session = Depends(get_db)):
# Only allow if no users exist (first admin)
user_count = db.query(User).count()
if user_count > 0:
raise HTTPException(status_code=403, detail="Registration disabled")
existing = db.query(User).filter(User.email == request.email).first()
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
user = User(
email=request.email,
password_hash=get_password_hash(request.password),
name=request.name,
role=UserRole.ADMIN,
)
db.add(user)
db.commit()
db.refresh(user)
return UserResponse.model_validate(user)

View File

@@ -0,0 +1,17 @@
from app.schemas.auth import (
LoginRequest,
LoginResponse,
TokenResponse,
RefreshRequest,
UserResponse,
CreateUserRequest,
)
__all__ = [
"LoginRequest",
"LoginResponse",
"TokenResponse",
"RefreshRequest",
"UserResponse",
"CreateUserRequest",
]

View File

@@ -0,0 +1,45 @@
from pydantic import BaseModel, EmailStr
from typing import Optional
from uuid import UUID
from app.models.user import UserRole, UserStatus
class LoginRequest(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class RefreshRequest(BaseModel):
refresh_token: str
class UserResponse(BaseModel):
id: UUID
email: str
name: str
role: UserRole
status: UserStatus
is_active: bool
class Config:
from_attributes = True
class LoginResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
user: UserResponse
class CreateUserRequest(BaseModel):
email: EmailStr
password: str
name: str
role: UserRole = UserRole.AGENT

View File

@@ -0,0 +1,99 @@
import { Router, Request, Response } from 'express';
import { SessionManager } from '../sessions/SessionManager';
export function createRouter(sessionManager: SessionManager): Router {
const router = Router();
// Health check
router.get('/health', (req: Request, res: Response) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Create new session
router.post('/sessions', async (req: Request, res: Response) => {
try {
const { accountId, name } = req.body;
if (!accountId || !name) {
return res.status(400).json({ error: 'accountId and name required' });
}
const session = await sessionManager.createSession(accountId, name);
res.json(session);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
// Get session info
router.get('/sessions/:accountId', (req: Request, res: Response) => {
const session = sessionManager.getSession(req.params.accountId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
res.json(session);
});
// Get all sessions
router.get('/sessions', (req: Request, res: Response) => {
const sessions = sessionManager.getAllSessions();
res.json(sessions);
});
// Disconnect session
router.post('/sessions/:accountId/disconnect', async (req: Request, res: Response) => {
try {
await sessionManager.disconnectSession(req.params.accountId);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
// Delete session
router.delete('/sessions/:accountId', async (req: Request, res: Response) => {
try {
await sessionManager.deleteSession(req.params.accountId);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
// Send message
router.post('/sessions/:accountId/messages', async (req: Request, res: Response) => {
try {
const { to, type, content } = req.body;
if (!to || !content) {
return res.status(400).json({ error: 'to and content required' });
}
let messageContent: any;
switch (type) {
case 'image':
messageContent = { image: { url: content.url }, caption: content.caption };
break;
case 'document':
messageContent = { document: { url: content.url }, fileName: content.filename };
break;
case 'audio':
messageContent = { audio: { url: content.url } };
break;
case 'video':
messageContent = { video: { url: content.url }, caption: content.caption };
break;
default:
messageContent = { text: content.text || content };
}
const result = await sessionManager.sendMessage(
req.params.accountId,
to,
messageContent
);
res.json({ success: true, messageId: result?.key.id });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
return router;
}

View File

@@ -0,0 +1,74 @@
import express from 'express';
import { createServer } from 'http';
import { Server as SocketIOServer } from 'socket.io';
import { SessionManager } from './sessions/SessionManager';
import { createRouter } from './api/routes';
import pino from 'pino';
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
const PORT = parseInt(process.env.WS_PORT || '3001', 10);
const API_GATEWAY_URL = process.env.API_GATEWAY_URL || 'http://localhost:8000';
async function main() {
const app = express();
const httpServer = createServer(app);
const io = new SocketIOServer(httpServer, {
cors: {
origin: '*',
methods: ['GET', 'POST'],
},
path: '/ws',
});
app.use(express.json());
const sessionManager = new SessionManager('./sessions');
const router = createRouter(sessionManager);
app.use('/api', router);
// Forward events to API Gateway and connected clients
sessionManager.on('session_event', async (event) => {
logger.info({ event }, 'Session event');
// Emit to Socket.IO clients
io.emit(event.type, event);
// Forward to API Gateway
try {
await fetch(`${API_GATEWAY_URL}/api/internal/whatsapp/event`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event),
});
} catch (error) {
logger.error({ error }, 'Failed to forward event to API Gateway');
}
});
io.on('connection', (socket) => {
logger.info({ socketId: socket.id }, 'Client connected');
socket.on('subscribe', (accountId: string) => {
socket.join(`account:${accountId}`);
logger.info({ socketId: socket.id, accountId }, 'Client subscribed');
});
socket.on('unsubscribe', (accountId: string) => {
socket.leave(`account:${accountId}`);
});
socket.on('disconnect', () => {
logger.info({ socketId: socket.id }, 'Client disconnected');
});
});
httpServer.listen(PORT, () => {
logger.info({ port: PORT }, 'WhatsApp Core server started');
});
}
main().catch((error) => {
logger.error({ error }, 'Failed to start server');
process.exit(1);
});