diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..2f09b14 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,33 @@ +import { Routes, Route, Navigate } from 'react-router-dom'; +import { useAuthStore } from './store/auth'; + +// Placeholder components - will be replaced +const LoginPage = () =>
Login Page
; +const DashboardPage = () =>
Dashboard
; +const MainLayout = ({ children }: { children: React.ReactNode }) =>
{children}
; + +function PrivateRoute({ children }: { children: React.ReactNode }) { + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + return isAuthenticated ? <>{children} : ; +} + +export default function App() { + return ( + + } /> + + + + } /> + } /> + + + + } + /> + + ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..54d330f --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,79 @@ +const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'; + +interface RequestOptions extends RequestInit { + params?: Record; +} + +class ApiClient { + private baseUrl: string; + + constructor(baseUrl: string) { + this.baseUrl = baseUrl; + } + + private getAuthHeaders(): Record { + const token = localStorage.getItem('access_token'); + return token ? { Authorization: `Bearer ${token}` } : {}; + } + + private async request(endpoint: string, options: RequestOptions = {}): Promise { + const { params, ...fetchOptions } = options; + + let url = `${this.baseUrl}${endpoint}`; + if (params) { + const searchParams = new URLSearchParams(params); + url += `?${searchParams.toString()}`; + } + + const headers: Record = { + 'Content-Type': 'application/json', + ...this.getAuthHeaders(), + ...(options.headers as Record), + }; + + 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(endpoint: string, options?: RequestOptions): Promise { + return this.request(endpoint, { ...options, method: 'GET' }); + } + + post(endpoint: string, data?: unknown, options?: RequestOptions): Promise { + return this.request(endpoint, { + ...options, + method: 'POST', + body: JSON.stringify(data), + }); + } + + put(endpoint: string, data?: unknown, options?: RequestOptions): Promise { + return this.request(endpoint, { + ...options, + method: 'PUT', + body: JSON.stringify(data), + }); + } + + delete(endpoint: string, options?: RequestOptions): Promise { + return this.request(endpoint, { ...options, method: 'DELETE' }); + } +} + +export const apiClient = new ApiClient(API_BASE_URL); diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..805aa9d --- /dev/null +++ b/frontend/src/index.css @@ -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; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..5617f14 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + + + + + + + +); diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts new file mode 100644 index 0000000..2e27552 --- /dev/null +++ b/frontend/src/store/auth.ts @@ -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) => void; +} + +export const useAuthStore = create()( + 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, + }), + } + ) +); diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..4ac6adf --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_URL: string; + readonly VITE_WS_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/services/api-gateway/app/core/__init__.py b/services/api-gateway/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/api-gateway/app/core/config.py b/services/api-gateway/app/core/config.py new file mode 100644 index 0000000..ebac1ea --- /dev/null +++ b/services/api-gateway/app/core/config.py @@ -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() diff --git a/services/api-gateway/app/core/database.py b/services/api-gateway/app/core/database.py new file mode 100644 index 0000000..28cf6ea --- /dev/null +++ b/services/api-gateway/app/core/database.py @@ -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() diff --git a/services/api-gateway/app/core/security.py b/services/api-gateway/app/core/security.py new file mode 100644 index 0000000..f2d04f1 --- /dev/null +++ b/services/api-gateway/app/core/security.py @@ -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 diff --git a/services/api-gateway/app/models/__init__.py b/services/api-gateway/app/models/__init__.py new file mode 100644 index 0000000..c8224b7 --- /dev/null +++ b/services/api-gateway/app/models/__init__.py @@ -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"] diff --git a/services/api-gateway/app/models/user.py b/services/api-gateway/app/models/user.py new file mode 100644 index 0000000..887faa8 --- /dev/null +++ b/services/api-gateway/app/models/user.py @@ -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) diff --git a/services/api-gateway/app/models/whatsapp.py b/services/api-gateway/app/models/whatsapp.py new file mode 100644 index 0000000..86274bc --- /dev/null +++ b/services/api-gateway/app/models/whatsapp.py @@ -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") diff --git a/services/api-gateway/app/routers/__init__.py b/services/api-gateway/app/routers/__init__.py new file mode 100644 index 0000000..4f04b08 --- /dev/null +++ b/services/api-gateway/app/routers/__init__.py @@ -0,0 +1,3 @@ +from app.routers.auth import router as auth_router + +__all__ = ["auth_router"] diff --git a/services/api-gateway/app/routers/auth.py b/services/api-gateway/app/routers/auth.py new file mode 100644 index 0000000..d8e0444 --- /dev/null +++ b/services/api-gateway/app/routers/auth.py @@ -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) diff --git a/services/api-gateway/app/schemas/__init__.py b/services/api-gateway/app/schemas/__init__.py new file mode 100644 index 0000000..9fa7c0e --- /dev/null +++ b/services/api-gateway/app/schemas/__init__.py @@ -0,0 +1,17 @@ +from app.schemas.auth import ( + LoginRequest, + LoginResponse, + TokenResponse, + RefreshRequest, + UserResponse, + CreateUserRequest, +) + +__all__ = [ + "LoginRequest", + "LoginResponse", + "TokenResponse", + "RefreshRequest", + "UserResponse", + "CreateUserRequest", +] diff --git a/services/api-gateway/app/schemas/auth.py b/services/api-gateway/app/schemas/auth.py new file mode 100644 index 0000000..2c448eb --- /dev/null +++ b/services/api-gateway/app/schemas/auth.py @@ -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 diff --git a/services/whatsapp-core/src/api/routes.ts b/services/whatsapp-core/src/api/routes.ts new file mode 100644 index 0000000..fe3a480 --- /dev/null +++ b/services/whatsapp-core/src/api/routes.ts @@ -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; +} diff --git a/services/whatsapp-core/src/index.ts b/services/whatsapp-core/src/index.ts new file mode 100644 index 0000000..6c37973 --- /dev/null +++ b/services/whatsapp-core/src/index.ts @@ -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); +});