Initial MCM project: FastAPI + Textual TUI unified messenger
MultiCustomerMessenger supporting Telegram (python-telegram-bot), WhatsApp (Green API) and SMS (python-gsmmodem-new). REST API with Bearer-token auth, SQLAlchemy models for MariaDB, APScheduler for background polling, and Textual TUI running in same asyncio event-loop.
This commit is contained in:
0
api/__init__.py
Normal file
0
api/__init__.py
Normal file
33
api/app.py
Normal file
33
api/app.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from api.routes import channels, contacts, conversations, messages
|
||||
|
||||
app = FastAPI(
|
||||
title="MCM – MultiCustomerMessenger API",
|
||||
description="Unified messaging gateway for Telegram, WhatsApp and SMS",
|
||||
version="1.0.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Routen
|
||||
app.include_router(messages.router, prefix="/api/v1")
|
||||
app.include_router(conversations.router, prefix="/api/v1")
|
||||
app.include_router(contacts.router, prefix="/api/v1")
|
||||
app.include_router(channels.router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.get("/health", tags=["system"])
|
||||
def health():
|
||||
return {"status": "ok", "service": "MCM", "timestamp": datetime.utcnow().isoformat()}
|
||||
18
api/auth.py
Normal file
18
api/auth.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from fastapi import HTTPException, Security, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from config import settings
|
||||
|
||||
_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def require_api_key(
|
||||
credentials: HTTPAuthorizationCredentials | None = Security(_bearer),
|
||||
) -> str:
|
||||
if not credentials or credentials.credentials != settings.api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or missing API key",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return credentials.credentials
|
||||
0
api/routes/__init__.py
Normal file
0
api/routes/__init__.py
Normal file
48
api/routes/channels.py
Normal file
48
api/routes/channels.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from api.auth import require_api_key
|
||||
from config import settings
|
||||
from schemas import ChannelStatusResponse, ChannelType, SystemStatusResponse
|
||||
|
||||
router = APIRouter(prefix="/channels", tags=["channels"])
|
||||
|
||||
# Wird in main.py gesetzt
|
||||
_channel_registry: dict = {}
|
||||
|
||||
|
||||
def register(telegram: object, whatsapp: object, sms: object) -> None:
|
||||
_channel_registry["telegram"] = telegram
|
||||
_channel_registry["whatsapp"] = whatsapp
|
||||
_channel_registry["sms"] = sms
|
||||
|
||||
|
||||
@router.get("/status", response_model=SystemStatusResponse)
|
||||
async def channel_status(_: str = Depends(require_api_key)):
|
||||
statuses = []
|
||||
|
||||
for name, channel_type in [
|
||||
("telegram", ChannelType.telegram),
|
||||
("whatsapp", ChannelType.whatsapp),
|
||||
("sms", ChannelType.sms),
|
||||
]:
|
||||
ch = _channel_registry.get(name)
|
||||
if ch is None:
|
||||
statuses.append(
|
||||
ChannelStatusResponse(channel=channel_type, enabled=False, connected=False)
|
||||
)
|
||||
continue
|
||||
connected, detail = await ch.check_connection()
|
||||
enabled = (
|
||||
settings.telegram_enabled
|
||||
if name == "telegram"
|
||||
else (settings.whatsapp_enabled if name == "whatsapp" else settings.sms_enabled)
|
||||
)
|
||||
statuses.append(
|
||||
ChannelStatusResponse(
|
||||
channel=channel_type, enabled=enabled, connected=connected, detail=detail
|
||||
)
|
||||
)
|
||||
|
||||
return SystemStatusResponse(channels=statuses, database=True, timestamp=datetime.utcnow())
|
||||
60
api/routes/contacts.py
Normal file
60
api/routes/contacts.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.auth import require_api_key
|
||||
from db.database import get_db
|
||||
from schemas import ContactCreate, ContactResponse, ContactUpdate
|
||||
from services import contact_service
|
||||
|
||||
router = APIRouter(prefix="/contacts", tags=["contacts"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[ContactResponse])
|
||||
def list_contacts(db: Session = Depends(get_db), _: str = Depends(require_api_key)):
|
||||
return contact_service.get_all(db)
|
||||
|
||||
|
||||
@router.post("/", response_model=ContactResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_contact(
|
||||
data: ContactCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(require_api_key),
|
||||
):
|
||||
return contact_service.create(db, data)
|
||||
|
||||
|
||||
@router.get("/{contact_id}", response_model=ContactResponse)
|
||||
def get_contact(
|
||||
contact_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(require_api_key),
|
||||
):
|
||||
contact = contact_service.get_by_id(db, contact_id)
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
return contact
|
||||
|
||||
|
||||
@router.put("/{contact_id}", response_model=ContactResponse)
|
||||
def update_contact(
|
||||
contact_id: str,
|
||||
data: ContactUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(require_api_key),
|
||||
):
|
||||
contact = contact_service.get_by_id(db, contact_id)
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
return contact_service.update(db, contact, data)
|
||||
|
||||
|
||||
@router.delete("/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_contact(
|
||||
contact_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(require_api_key),
|
||||
):
|
||||
contact = contact_service.get_by_id(db, contact_id)
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
contact_service.delete(db, contact)
|
||||
66
api/routes/conversations.py
Normal file
66
api/routes/conversations.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.auth import require_api_key
|
||||
from db.database import get_db
|
||||
from schemas import ConversationResponse, MessageResponse
|
||||
from services import conversation_service
|
||||
|
||||
router = APIRouter(prefix="/conversations", tags=["conversations"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[ConversationResponse])
|
||||
def list_conversations(
|
||||
channel: str | None = Query(None),
|
||||
archived: bool = Query(False),
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(require_api_key),
|
||||
):
|
||||
convs = conversation_service.get_all(db, channel=channel, archived=archived)
|
||||
result = []
|
||||
for conv in convs:
|
||||
last_msg = conv.messages[-1] if conv.messages else None
|
||||
result.append(
|
||||
ConversationResponse(
|
||||
**{
|
||||
c.key: getattr(conv, c.key)
|
||||
for c in conv.__table__.columns
|
||||
},
|
||||
last_message=MessageResponse.model_validate(last_msg) if last_msg else None,
|
||||
unread_count=conversation_service.unread_count(db, conv.id),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{conv_id}", response_model=ConversationResponse)
|
||||
def get_conversation(
|
||||
conv_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(require_api_key),
|
||||
):
|
||||
conv = conversation_service.get_by_id(db, conv_id)
|
||||
if not conv:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
last_msg = conv.messages[-1] if conv.messages else None
|
||||
return ConversationResponse(
|
||||
**{c.key: getattr(conv, c.key) for c in conv.__table__.columns},
|
||||
last_message=MessageResponse.model_validate(last_msg) if last_msg else None,
|
||||
unread_count=conversation_service.unread_count(db, conv.id),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{conv_id}/messages", response_model=list[MessageResponse])
|
||||
def get_messages(
|
||||
conv_id: str,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(require_api_key),
|
||||
):
|
||||
conv = conversation_service.get_by_id(db, conv_id)
|
||||
if not conv:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
msgs = conversation_service.get_messages(db, conv_id, limit=limit, offset=offset)
|
||||
conversation_service.mark_all_read(db, conv_id)
|
||||
return [MessageResponse.model_validate(m) for m in msgs]
|
||||
44
api/routes/messages.py
Normal file
44
api/routes/messages.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.auth import require_api_key
|
||||
from db.database import get_db
|
||||
from schemas import MessageResponse, SendMessageRequest
|
||||
from services import message_service
|
||||
|
||||
router = APIRouter(prefix="/messages", tags=["messages"])
|
||||
|
||||
|
||||
@router.post("/", response_model=MessageResponse, status_code=201)
|
||||
async def send_message(
|
||||
req: SendMessageRequest,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(require_api_key),
|
||||
):
|
||||
if req.channel == "telegram" and not req.recipient_telegram_id:
|
||||
raise HTTPException(status_code=422, detail="recipient_telegram_id required for Telegram")
|
||||
if req.channel in ("whatsapp", "sms") and not req.recipient_phone:
|
||||
raise HTTPException(status_code=422, detail="recipient_phone required for WhatsApp/SMS")
|
||||
|
||||
msg = await message_service.send(db, req)
|
||||
return MessageResponse.model_validate(msg)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[MessageResponse])
|
||||
def list_messages(
|
||||
conversation_id: str | None = Query(None),
|
||||
channel: str | None = Query(None),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(require_api_key),
|
||||
):
|
||||
from db.models import Message
|
||||
|
||||
q = db.query(Message)
|
||||
if conversation_id:
|
||||
q = q.filter(Message.conversation_id == conversation_id)
|
||||
if channel:
|
||||
q = q.filter(Message.channel == channel)
|
||||
msgs = q.order_by(Message.created_at.desc()).offset(offset).limit(limit).all()
|
||||
return [MessageResponse.model_validate(m) for m in msgs]
|
||||
Reference in New Issue
Block a user