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:
2026-03-03 14:43:19 +01:00
commit 7f3b4768c3
38 changed files with 2072 additions and 0 deletions

0
api/routes/__init__.py Normal file
View File

48
api/routes/channels.py Normal file
View 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
View 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)

View 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
View 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]