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

24
.env.example Normal file
View File

@@ -0,0 +1,24 @@
# MCM MultiCustomerMessenger Konfiguration
# Datei kopieren: cp .env.example .env
# ── API ────────────────────────────────────────────────────
API_KEY=changeme-secret-key
HOST=0.0.0.0
PORT=8000
DEBUG=false
# ── Telegram ───────────────────────────────────────────────
TELEGRAM_TOKEN=
# ── WhatsApp (Green API) ───────────────────────────────────
# Konto anlegen unter: https://console.green-api.com
WHATSAPP_ID_INSTANCE=
WHATSAPP_API_TOKEN=
# ── SMS / USB-Modem ────────────────────────────────────────
SMS_PORT=/dev/ttyUSB0
SMS_BAUD_RATE=115200
SMS_ENABLED=false
# ── Datenbank ──────────────────────────────────────────────
DATABASE_URL=sqlite:///./mcm.db

38
.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
.Python
*.egg
*.egg-info/
dist/
build/
.eggs/
# Virtualenv
.venv/
venv/
env/
# Umgebung
.env
# Datenbank
*.db
*.db-shm
*.db-wal
# Logs
*.log
logs/
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db

0
api/__init__.py Normal file
View File

33
api/app.py Normal file
View 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
View 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
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]

0
channels/__init__.py Normal file
View File

37
channels/base.py Normal file
View File

@@ -0,0 +1,37 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class BaseChannel(ABC):
"""Abstrakte Basisklasse für alle Messaging-Kanäle."""
@abstractmethod
async def send_message(
self,
recipient: str,
text: str,
reply_to_id: str | None = None,
) -> dict[str, Any]:
"""Nachricht senden.
Returns:
{"success": bool, "channel_message_id": str | None, "error": str | None}
"""
@abstractmethod
async def check_connection(self) -> tuple[bool, str]:
"""Verbindung prüfen.
Returns:
(connected: bool, detail: str)
"""
@abstractmethod
async def start(self) -> None:
"""Kanal starten (Webhook registrieren, Polling starten, …)."""
@abstractmethod
async def stop(self) -> None:
"""Kanal sauber beenden."""

97
channels/sms_channel.py Normal file
View File

@@ -0,0 +1,97 @@
from __future__ import annotations
import asyncio
import logging
from typing import Any, Callable, Awaitable
from channels.base import BaseChannel
from config import settings
logger = logging.getLogger(__name__)
class SmsChannel(BaseChannel):
"""SMS-Kanal via python-gsmmodem-new (USB-Modem)."""
def __init__(self) -> None:
self._modem: Any = None # gsmmodem.modem.GsmModem
self._inbound_callback: Callable[[dict[str, Any]], Awaitable[None]] | None = None
self._loop: asyncio.AbstractEventLoop | None = None
def set_inbound_callback(self, cb: Callable[[dict[str, Any]], Awaitable[None]]) -> None:
self._inbound_callback = cb
# ── BaseChannel interface ──────────────────────────────────────────────────
async def send_message(
self,
recipient: str,
text: str,
reply_to_id: str | None = None,
) -> dict[str, Any]:
if not settings.sms_enabled or not self._modem:
return {"success": False, "channel_message_id": None, "error": "SMS not available"}
try:
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._modem.sendSms, recipient, text)
return {"success": True, "channel_message_id": None, "error": None}
except Exception as exc:
logger.error("SMS send error: %s", exc)
return {"success": False, "channel_message_id": None, "error": str(exc)}
async def check_connection(self) -> tuple[bool, str]:
if not settings.sms_enabled:
return False, "SMS disabled in config"
if not self._modem:
return False, "Modem not initialized"
try:
loop = asyncio.get_event_loop()
signal = await loop.run_in_executor(None, self._modem.signalStrength)
return True, f"Signal: {signal}%"
except Exception as exc:
return False, str(exc)
async def start(self) -> None:
if not settings.sms_enabled:
logger.info("SMS disabled (sms_enabled=false in config)")
return
self._loop = asyncio.get_event_loop()
try:
from gsmmodem.modem import GsmModem # type: ignore
modem = GsmModem(
settings.sms_port,
settings.sms_baud_rate,
incomingSmsCallbackFunc=self._sms_received_sync,
)
await self._loop.run_in_executor(None, modem.connect)
self._modem = modem
logger.info("SMS channel started on %s", settings.sms_port)
except Exception as exc:
logger.error("SMS channel start error: %s", exc)
async def stop(self) -> None:
if self._modem:
try:
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._modem.close)
except Exception:
pass
logger.info("SMS channel stopped")
# ── Interner Callback (sync → async bridge) ────────────────────────────────
def _sms_received_sync(self, sms: Any) -> None:
"""Wird vom Modem-Thread aufgerufen leitet an async weiter."""
if not self._inbound_callback or not self._loop:
return
payload: dict[str, Any] = {
"channel": "sms",
"channel_message_id": None,
"sender_phone": sms.number,
"sender_name": sms.number,
"chat_id": sms.number,
"text": sms.text,
"reply_to_id": None,
}
asyncio.run_coroutine_threadsafe(self._inbound_callback(payload), self._loop)

View File

@@ -0,0 +1,112 @@
from __future__ import annotations
import asyncio
import logging
from typing import TYPE_CHECKING, Any, Callable, Awaitable
from telegram import Update
from telegram.ext import Application, ApplicationBuilder, MessageHandler, filters
from channels.base import BaseChannel
from config import settings
if TYPE_CHECKING:
pass
logger = logging.getLogger(__name__)
class TelegramChannel(BaseChannel):
"""Telegram-Kanal via python-telegram-bot (Long-Polling)."""
def __init__(self) -> None:
self._app: Application | None = None
self._inbound_callback: Callable[[dict[str, Any]], Awaitable[None]] | None = None
def set_inbound_callback(self, cb: Callable[[dict[str, Any]], Awaitable[None]]) -> None:
"""Callback, der bei eingehenden Nachrichten aufgerufen wird."""
self._inbound_callback = cb
# ── BaseChannel interface ──────────────────────────────────────────────────
async def send_message(
self,
recipient: str,
text: str,
reply_to_id: str | None = None,
) -> dict[str, Any]:
if not self._app:
return {"success": False, "channel_message_id": None, "error": "Telegram not initialized"}
try:
kwargs: dict[str, Any] = {"chat_id": recipient, "text": text}
if reply_to_id:
kwargs["reply_to_message_id"] = int(reply_to_id)
msg = await self._app.bot.send_message(**kwargs)
return {"success": True, "channel_message_id": str(msg.message_id), "error": None}
except Exception as exc:
logger.error("Telegram send error: %s", exc)
return {"success": False, "channel_message_id": None, "error": str(exc)}
async def check_connection(self) -> tuple[bool, str]:
if not self._app:
return False, "Not initialized"
try:
me = await self._app.bot.get_me()
return True, f"@{me.username}"
except Exception as exc:
return False, str(exc)
async def start(self) -> None:
if not settings.telegram_enabled:
logger.info("Telegram disabled (no token configured)")
return
self._app = ApplicationBuilder().token(settings.telegram_token).build()
self._app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self._handle_message))
self._app.add_handler(MessageHandler(filters.PHOTO | filters.DOCUMENT | filters.VOICE, self._handle_message))
await self._app.initialize()
await self._app.start()
# Long-Polling als Background-Task
asyncio.create_task(self._polling_loop(), name="telegram-polling")
logger.info("Telegram channel started (long-polling)")
async def stop(self) -> None:
if self._app:
await self._app.stop()
await self._app.shutdown()
logger.info("Telegram channel stopped")
# ── Interner Handler ───────────────────────────────────────────────────────
async def _polling_loop(self) -> None:
"""Endlos-Polling im Hintergrund."""
try:
await self._app.updater.start_polling(allowed_updates=["message"])
# Warte bis gestoppt
await self._app.updater.idle()
except asyncio.CancelledError:
pass
except Exception as exc:
logger.error("Telegram polling error: %s", exc)
async def _handle_message(self, update: Update, context: Any) -> None:
if not update.message or not self._inbound_callback:
return
msg = update.message
payload: dict[str, Any] = {
"channel": "telegram",
"channel_message_id": str(msg.message_id),
"sender_telegram_id": str(msg.from_user.id) if msg.from_user else None,
"sender_name": (
(msg.from_user.full_name or msg.from_user.username)
if msg.from_user
else "Unknown"
),
"chat_id": str(msg.chat.id),
"text": msg.text or msg.caption or "",
"reply_to_id": (
str(msg.reply_to_message.message_id) if msg.reply_to_message else None
),
}
await self._inbound_callback(payload)

View File

@@ -0,0 +1,133 @@
from __future__ import annotations
import logging
from typing import Any, Callable, Awaitable
import aiohttp
from channels.base import BaseChannel
from config import settings
logger = logging.getLogger(__name__)
class WhatsAppChannel(BaseChannel):
"""WhatsApp-Kanal via Green API (https://green-api.com)."""
def __init__(self) -> None:
self._base_url = (
f"https://api.green-api.com/waInstance{settings.whatsapp_id_instance}"
)
self._token = settings.whatsapp_api_token
self._session: aiohttp.ClientSession | None = None
self._inbound_callback: Callable[[dict[str, Any]], Awaitable[None]] | None = None
def set_inbound_callback(self, cb: Callable[[dict[str, Any]], Awaitable[None]]) -> None:
self._inbound_callback = cb
# ── BaseChannel interface ──────────────────────────────────────────────────
async def send_message(
self,
recipient: str,
text: str,
reply_to_id: str | None = None,
) -> dict[str, Any]:
if not settings.whatsapp_enabled:
return {"success": False, "channel_message_id": None, "error": "WhatsApp not configured"}
# chatId: Telefonnummer ohne + gefolgt von @c.us, z.B. "4917612345678@c.us"
chat_id = recipient.lstrip("+") + "@c.us"
url = f"{self._base_url}/sendMessage/{self._token}"
body: dict[str, Any] = {"chatId": chat_id, "message": text}
if reply_to_id:
body["quotedMessageId"] = reply_to_id
try:
async with self._get_session().post(url, json=body) as resp:
data = await resp.json()
if resp.status == 200 and "idMessage" in data:
return {"success": True, "channel_message_id": data["idMessage"], "error": None}
return {"success": False, "channel_message_id": None, "error": str(data)}
except Exception as exc:
logger.error("WhatsApp send error: %s", exc)
return {"success": False, "channel_message_id": None, "error": str(exc)}
async def check_connection(self) -> tuple[bool, str]:
if not settings.whatsapp_enabled:
return False, "Not configured"
url = f"{self._base_url}/getStateInstance/{self._token}"
try:
async with self._get_session().get(url) as resp:
data = await resp.json()
state = data.get("stateInstance", "unknown")
return state == "authorized", state
except Exception as exc:
return False, str(exc)
async def start(self) -> None:
if not settings.whatsapp_enabled:
logger.info("WhatsApp disabled (no credentials configured)")
return
self._session = aiohttp.ClientSession()
logger.info("WhatsApp channel started")
async def stop(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
logger.info("WhatsApp channel stopped")
# ── Polling (wird vom Scheduler aufgerufen) ────────────────────────────────
async def poll_incoming(self) -> None:
"""Eingehende Nachrichten per Polling abrufen (Green API Notification Queue)."""
if not settings.whatsapp_enabled:
return
url = f"{self._base_url}/receiveNotification/{self._token}"
try:
async with self._get_session().get(url, params={"receiveTimeout": 5}) as resp:
if resp.status != 200:
return
data = await resp.json()
if not data:
return
receipt_id = data.get("receiptId")
body = data.get("body", {})
await self._process_notification(body)
if receipt_id:
await self._delete_notification(receipt_id)
except Exception as exc:
logger.error("WhatsApp poll error: %s", exc)
async def _delete_notification(self, receipt_id: int) -> None:
url = f"{self._base_url}/deleteNotification/{self._token}/{receipt_id}"
try:
async with self._get_session().delete(url) as resp:
await resp.read()
except Exception as exc:
logger.warning("WhatsApp delete notification error: %s", exc)
async def _process_notification(self, body: dict[str, Any]) -> None:
if not self._inbound_callback:
return
msg_type = body.get("typeWebhook")
if msg_type != "incomingMessageReceived":
return
sender_data = body.get("senderData", {})
message_data = body.get("messageData", {})
text_data = message_data.get("textMessageData", {})
payload: dict[str, Any] = {
"channel": "whatsapp",
"channel_message_id": body.get("idMessage"),
"sender_phone": sender_data.get("sender", "").replace("@c.us", ""),
"sender_name": sender_data.get("senderName", ""),
"chat_id": sender_data.get("chatId", ""),
"text": text_data.get("textMessage", ""),
"reply_to_id": None,
}
await self._inbound_callback(payload)
def _get_session(self) -> aiohttp.ClientSession:
if not self._session or self._session.closed:
self._session = aiohttp.ClientSession()
return self._session

43
config.py Normal file
View File

@@ -0,0 +1,43 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
# API
api_key: str = "changeme-secret-key"
host: str = "0.0.0.0"
port: int = 8000
debug: bool = False
# Telegram
telegram_token: str = ""
# WhatsApp (Green API)
whatsapp_id_instance: str = ""
whatsapp_api_token: str = ""
# SMS / USB-Modem
sms_port: str = "/dev/ttyUSB0"
sms_baud_rate: int = 115200
sms_enabled: bool = False
# Datenbank
database_url: str = "sqlite:///./mcm.db"
@property
def telegram_enabled(self) -> bool:
return bool(self.telegram_token)
@property
def whatsapp_enabled(self) -> bool:
return bool(self.whatsapp_id_instance and self.whatsapp_api_token)
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()

0
db/__init__.py Normal file
View File

38
db/database.py Normal file
View File

@@ -0,0 +1,38 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from sqlalchemy.pool import StaticPool
from config import settings
class Base(DeclarativeBase):
pass
_connect_args = {}
_pool_class = None
if settings.database_url.startswith("sqlite"):
_connect_args = {"check_same_thread": False}
_pool_class = StaticPool
engine = create_engine(
settings.database_url,
connect_args=_connect_args,
**({"poolclass": _pool_class} if _pool_class else {}),
echo=settings.debug,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db() -> None:
from db import models # noqa: F401 Modelle müssen importiert sein
Base.metadata.create_all(bind=engine)

144
db/models.py Normal file
View File

@@ -0,0 +1,144 @@
import uuid
from datetime import datetime
from sqlalchemy import (
Boolean,
Column,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
String,
Table,
Text,
)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from db.database import Base
def _uuid() -> str:
return str(uuid.uuid4())
# ── Many-to-many: Conversation ↔ Contact ──────────────────────────────────────
conversation_participants = Table(
"conversation_participants",
Base.metadata,
Column("conversation_id", String, ForeignKey("conversations.id", ondelete="CASCADE")),
Column("contact_id", String, ForeignKey("contacts.id", ondelete="CASCADE")),
)
# ── Contact ────────────────────────────────────────────────────────────────────
class Contact(Base):
__tablename__ = "contacts"
id = Column(String, primary_key=True, default=_uuid)
name = Column(String(255), nullable=False, index=True)
phone = Column(String(32), nullable=True, unique=True)
email = Column(String(255), nullable=True)
telegram_id = Column(String(64), nullable=True, unique=True)
telegram_username = Column(String(255), nullable=True)
whatsapp_phone = Column(String(32), nullable=True)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=func.now(), nullable=False)
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
conversations = relationship(
"Conversation",
secondary=conversation_participants,
back_populates="participants",
)
messages_sent = relationship(
"Message",
back_populates="sender",
foreign_keys="Message.sender_id",
)
# ── Conversation ───────────────────────────────────────────────────────────────
class Conversation(Base):
__tablename__ = "conversations"
id = Column(String, primary_key=True, default=_uuid)
channel = Column(
Enum("telegram", "whatsapp", "sms", name="channel_type"),
nullable=False,
index=True,
)
channel_conversation_id = Column(String(255), nullable=True)
title = Column(String(255), nullable=True)
is_group = Column(Boolean, default=False, nullable=False)
is_archived = Column(Boolean, default=False, nullable=False)
last_message_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=func.now(), nullable=False)
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
participants = relationship(
"Contact",
secondary=conversation_participants,
back_populates="conversations",
)
messages = relationship(
"Message",
back_populates="conversation",
cascade="all, delete-orphan",
order_by="Message.created_at",
)
__table_args__ = (
Index("idx_conv_channel_native", "channel", "channel_conversation_id"),
)
# ── Message ────────────────────────────────────────────────────────────────────
class Message(Base):
__tablename__ = "messages"
id = Column(String, primary_key=True, default=_uuid)
conversation_id = Column(
String, ForeignKey("conversations.id", ondelete="CASCADE"), nullable=False, index=True
)
sender_id = Column(String, ForeignKey("contacts.id"), nullable=True)
channel = Column(
Enum("telegram", "whatsapp", "sms", name="channel_type"),
nullable=False,
index=True,
)
channel_message_id = Column(String(255), nullable=True)
direction = Column(
Enum("inbound", "outbound", name="message_direction"),
nullable=False,
default="outbound",
)
text = Column(Text, nullable=False)
status = Column(
Enum("pending", "sent", "delivered", "read", "failed", name="message_status"),
default="pending",
nullable=False,
index=True,
)
reply_to_id = Column(String, ForeignKey("messages.id"), nullable=True)
is_edited = Column(Boolean, default=False, nullable=False)
error_message = Column(Text, nullable=True)
retry_count = Column(Integer, default=0, nullable=False)
created_at = Column(DateTime, default=func.now(), nullable=False, index=True)
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
sent_at = Column(DateTime, nullable=True)
delivered_at = Column(DateTime, nullable=True)
read_at = Column(DateTime, nullable=True)
conversation = relationship("Conversation", back_populates="messages")
sender = relationship("Contact", back_populates="messages_sent", foreign_keys=[sender_id])
reply_to = relationship("Message", remote_side="Message.id", backref="replies")
__table_args__ = (
Index("idx_msg_channel_native", "channel", "channel_message_id"),
Index("idx_msg_conv_created", "conversation_id", "created_at"),
)

72
install/README_install.md Normal file
View File

@@ -0,0 +1,72 @@
# MCM Installation auf Raspberry Pi 4 (Debian)
## 1. Abhängigkeiten installieren
```bash
sudo apt update
sudo apt install -y python3 python3-venv python3-pip mariadb-server usb-modeswitch
```
## 2. MariaDB einrichten
```bash
sudo mysql_secure_installation
sudo mysql -u root -p <<EOF
CREATE DATABASE mcm CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'mcm'@'localhost' IDENTIFIED BY 'SICHERES_PASSWORT';
GRANT ALL PRIVILEGES ON mcm.* TO 'mcm'@'localhost';
FLUSH PRIVILEGES;
EOF
```
## 3. Projekt deployen
```bash
sudo mkdir -p /opt/mcm
sudo cp -r /home/pi/MCM/* /opt/mcm/
sudo python3 -m venv /opt/mcm/.venv
sudo /opt/mcm/.venv/bin/pip install -r /opt/mcm/requirements.txt
```
## 4. Konfiguration
```bash
sudo cp /opt/mcm/.env.example /opt/mcm/.env
sudo nano /opt/mcm/.env
# DATABASE_URL=mysql+pymysql://mcm:PASSWORT@localhost:3306/mcm
# TELEGRAM_TOKEN=...
# WHATSAPP_ID_INSTANCE=...
# WHATSAPP_API_TOKEN=...
# API_KEY=sicherer-key
```
## 5. systemd-Dienst aktivieren
```bash
sudo cp /opt/mcm/install/mcm.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable mcm
sudo systemctl start mcm
sudo systemctl status mcm
```
## 6. TUI manuell starten
```bash
cd /opt/mcm
.venv/bin/python main.py
```
## USB-Modem
Das Modem erscheint typischerweise als `/dev/ttyUSB0`. Prüfen mit:
```bash
lsusb
dmesg | grep tty
```
`usb-modeswitch` schaltet das Gerät von Speicher- in Modem-Modus.
Der Dienst-User braucht Mitgliedschaft in der Gruppe `dialout`:
```bash
sudo usermod -aG dialout pi
```

24
install/mcm.service Normal file
View File

@@ -0,0 +1,24 @@
[Unit]
Description=MCM MultiCustomerMessenger API
Documentation=https://gitea.it-drui.de/viewit/MCM
After=network-online.target mariadb.service
Wants=network-online.target
[Service]
Type=simple
User=pi
Group=pi
WorkingDirectory=/opt/mcm
EnvironmentFile=/opt/mcm/.env
ExecStart=/opt/mcm/.venv/bin/python main_api_only.py
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=mcm
# Modem-Gerät Zugriff
SupplementaryGroups=dialout
[Install]
WantedBy=multi-user.target

103
main.py Normal file
View File

@@ -0,0 +1,103 @@
"""MCM MultiCustomerMessenger
Entry Point: Startet FastAPI (Uvicorn) und Textual TUI im selben asyncio-Event-Loop.
Für den systemd-Dienst (ohne TUI) siehe main_api_only.py.
"""
from __future__ import annotations
import asyncio
import logging
import sys
import uvicorn
from api.app import app as fastapi_app
from api.routes import channels as channels_router
from channels.sms_channel import SmsChannel
from channels.telegram_channel import TelegramChannel
from channels.whatsapp_channel import WhatsAppChannel
from config import settings
from db.database import init_db
from services import message_service
from tasks.receiver import build_scheduler
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("mcm")
async def _run_api() -> None:
"""Uvicorn als Task in der bestehenden Event-Loop."""
config = uvicorn.Config(
fastapi_app,
host=settings.host,
port=settings.port,
log_level="warning",
access_log=False,
)
server = uvicorn.Server(config)
await server.serve()
async def main(with_tui: bool = True) -> None:
logger.info("MCM starting…")
# 1. Datenbank initialisieren
init_db()
# 2. Kanäle instanziieren
telegram = TelegramChannel()
whatsapp = WhatsAppChannel()
sms = SmsChannel()
# 3. Inbound-Callback setzen
telegram.set_inbound_callback(message_service.handle_inbound)
whatsapp.set_inbound_callback(message_service.handle_inbound)
sms.set_inbound_callback(message_service.handle_inbound)
# 4. Channel-Referenzen in Services/Routes registrieren
message_service.register_channels(telegram, whatsapp, sms)
channels_router.register(telegram, whatsapp, sms)
# 5. Kanäle starten
await telegram.start()
await whatsapp.start()
await sms.start()
# 6. Hintergrund-Tasks starten (WhatsApp-Polling etc.)
scheduler = build_scheduler(whatsapp)
scheduler.start()
# 7. Uvicorn als Hintergrund-Task starten
api_task = asyncio.create_task(_run_api(), name="mcm-api")
logger.info("API running on http://%s:%d", settings.host, settings.port)
try:
if with_tui:
# 8a. TUI starten (blockiert bis der Nutzer beendet)
from tui.app import MCMApp
tui = MCMApp()
await tui.run_async()
else:
# 8b. Nur API wartet auf Ctrl-C / SIGTERM
logger.info("Running in API-only mode (no TUI)")
await api_task
finally:
logger.info("MCM shutting down…")
scheduler.shutdown(wait=False)
await telegram.stop()
await whatsapp.stop()
await sms.stop()
api_task.cancel()
if __name__ == "__main__":
no_tui = "--no-tui" in sys.argv
try:
asyncio.run(main(with_tui=not no_tui))
except KeyboardInterrupt:
pass

12
main_api_only.py Normal file
View File

@@ -0,0 +1,12 @@
"""MCM API-only Einstiegspunkt wird vom systemd-Dienst verwendet."""
import asyncio
import sys
from main import main
if __name__ == "__main__":
try:
asyncio.run(main(with_tui=False))
except KeyboardInterrupt:
sys.exit(0)

13
requirements.txt Normal file
View File

@@ -0,0 +1,13 @@
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
pydantic>=2.7.0
pydantic-settings>=2.3.0
sqlalchemy>=2.0.30
PyMySQL>=1.1.0
cryptography>=42.0.0
python-telegram-bot[job-queue]>=21.0
aiohttp>=3.9.0
python-gsmmodem-new>=0.10
apscheduler>=3.10.4
textual>=0.75.0
python-dotenv>=1.0.0

128
schemas.py Normal file
View File

@@ -0,0 +1,128 @@
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
# ── Enums ──────────────────────────────────────────────────────────────────────
class ChannelType(str, Enum):
telegram = "telegram"
whatsapp = "whatsapp"
sms = "sms"
class MessageStatus(str, Enum):
pending = "pending"
sent = "sent"
delivered = "delivered"
read = "read"
failed = "failed"
class MessageDirection(str, Enum):
inbound = "inbound"
outbound = "outbound"
# ── Contact ────────────────────────────────────────────────────────────────────
class ContactCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
phone: Optional[str] = None
email: Optional[str] = None
telegram_id: Optional[str] = None
telegram_username: Optional[str] = None
whatsapp_phone: Optional[str] = None
notes: Optional[str] = None
class ContactUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255)
phone: Optional[str] = None
email: Optional[str] = None
telegram_id: Optional[str] = None
telegram_username: Optional[str] = None
whatsapp_phone: Optional[str] = None
notes: Optional[str] = None
class ContactResponse(BaseModel):
model_config = {"from_attributes": True}
id: str
name: str
phone: Optional[str] = None
email: Optional[str] = None
telegram_id: Optional[str] = None
telegram_username: Optional[str] = None
whatsapp_phone: Optional[str] = None
notes: Optional[str] = None
created_at: datetime
updated_at: Optional[datetime] = None
# ── Message ────────────────────────────────────────────────────────────────────
class SendMessageRequest(BaseModel):
channel: ChannelType
# Empfänger je nach Kanal eines der Felder befüllen
recipient_phone: Optional[str] = Field(None, description="Telefonnummer für WhatsApp/SMS (E.164: +49…)")
recipient_telegram_id: Optional[str] = Field(None, description="Telegram Chat-ID")
# Optional: Kontakt-ID aus der DB
contact_id: Optional[str] = None
text: str = Field(..., min_length=1, max_length=4096)
reply_to_id: Optional[str] = None
class MessageResponse(BaseModel):
model_config = {"from_attributes": True}
id: str
conversation_id: str
sender_id: Optional[str] = None
channel: ChannelType
channel_message_id: Optional[str] = None
direction: MessageDirection
text: str
status: MessageStatus
error_message: Optional[str] = None
created_at: datetime
sent_at: Optional[datetime] = None
delivered_at: Optional[datetime] = None
read_at: Optional[datetime] = None
# ── Conversation ───────────────────────────────────────────────────────────────
class ConversationResponse(BaseModel):
model_config = {"from_attributes": True}
id: str
channel: ChannelType
channel_conversation_id: Optional[str] = None
title: Optional[str] = None
is_group: bool
is_archived: bool
last_message_at: Optional[datetime] = None
created_at: datetime
last_message: Optional[MessageResponse] = None
unread_count: int = 0
# ── Channel Status ─────────────────────────────────────────────────────────────
class ChannelStatusResponse(BaseModel):
channel: ChannelType
enabled: bool
connected: bool
detail: Optional[str] = None
class SystemStatusResponse(BaseModel):
channels: list[ChannelStatusResponse]
database: bool
timestamp: datetime

0
services/__init__.py Normal file
View File

View File

@@ -0,0 +1,73 @@
from __future__ import annotations
from sqlalchemy.orm import Session
from db.models import Contact
from schemas import ContactCreate, ContactUpdate
def get_all(db: Session) -> list[Contact]:
return db.query(Contact).order_by(Contact.name).all()
def get_by_id(db: Session, contact_id: str) -> Contact | None:
return db.query(Contact).filter(Contact.id == contact_id).first()
def get_by_telegram_id(db: Session, telegram_id: str) -> Contact | None:
return db.query(Contact).filter(Contact.telegram_id == telegram_id).first()
def get_by_phone(db: Session, phone: str) -> Contact | None:
return (
db.query(Contact)
.filter((Contact.phone == phone) | (Contact.whatsapp_phone == phone))
.first()
)
def create(db: Session, data: ContactCreate) -> Contact:
contact = Contact(**data.model_dump(exclude_none=False))
db.add(contact)
db.commit()
db.refresh(contact)
return contact
def update(db: Session, contact: Contact, data: ContactUpdate) -> Contact:
for field, value in data.model_dump(exclude_unset=True).items():
setattr(contact, field, value)
db.commit()
db.refresh(contact)
return contact
def delete(db: Session, contact: Contact) -> None:
db.delete(contact)
db.commit()
def get_or_create_by_telegram(
db: Session, telegram_id: str, name: str, username: str | None = None
) -> Contact:
contact = get_by_telegram_id(db, telegram_id)
if not contact:
contact = create(
db,
ContactCreate(
name=name,
telegram_id=telegram_id,
telegram_username=username,
),
)
return contact
def get_or_create_by_phone(db: Session, phone: str, name: str | None = None) -> Contact:
contact = get_by_phone(db, phone)
if not contact:
contact = create(
db,
ContactCreate(name=name or phone, phone=phone, whatsapp_phone=phone),
)
return contact

View File

@@ -0,0 +1,85 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy.orm import Session
from db.models import Contact, Conversation, Message
def get_all(db: Session, channel: str | None = None, archived: bool = False) -> list[Conversation]:
q = db.query(Conversation).filter(Conversation.is_archived == archived)
if channel:
q = q.filter(Conversation.channel == channel)
return q.order_by(Conversation.last_message_at.desc().nullslast()).all()
def get_by_id(db: Session, conv_id: str) -> Conversation | None:
return db.query(Conversation).filter(Conversation.id == conv_id).first()
def get_by_channel_id(db: Session, channel: str, channel_conversation_id: str) -> Conversation | None:
return (
db.query(Conversation)
.filter(
Conversation.channel == channel,
Conversation.channel_conversation_id == channel_conversation_id,
)
.first()
)
def get_or_create(
db: Session,
channel: str,
channel_conversation_id: str,
contact: Contact,
title: str | None = None,
) -> Conversation:
conv = get_by_channel_id(db, channel, channel_conversation_id)
if not conv:
conv = Conversation(
channel=channel,
channel_conversation_id=channel_conversation_id,
title=title or contact.name,
)
conv.participants.append(contact)
db.add(conv)
db.commit()
db.refresh(conv)
return conv
def get_messages(
db: Session, conv_id: str, limit: int = 50, offset: int = 0
) -> list[Message]:
return (
db.query(Message)
.filter(Message.conversation_id == conv_id)
.order_by(Message.created_at.asc())
.offset(offset)
.limit(limit)
.all()
)
def unread_count(db: Session, conv_id: str) -> int:
return (
db.query(Message)
.filter(
Message.conversation_id == conv_id,
Message.direction == "inbound",
Message.read_at.is_(None),
)
.count()
)
def mark_all_read(db: Session, conv_id: str) -> None:
now = datetime.utcnow()
db.query(Message).filter(
Message.conversation_id == conv_id,
Message.direction == "inbound",
Message.read_at.is_(None),
).update({"read_at": now, "status": "read"})
db.commit()

182
services/message_service.py Normal file
View File

@@ -0,0 +1,182 @@
from __future__ import annotations
import logging
from datetime import datetime
from typing import TYPE_CHECKING, Any
from sqlalchemy.orm import Session
from db.database import SessionLocal
from db.models import Message
from schemas import SendMessageRequest
from services import contact_service, conversation_service
if TYPE_CHECKING:
from channels.telegram_channel import TelegramChannel
from channels.whatsapp_channel import WhatsAppChannel
from channels.sms_channel import SmsChannel
logger = logging.getLogger(__name__)
# Wird in main.py nach Initialisierung gesetzt
_telegram: "TelegramChannel | None" = None
_whatsapp: "WhatsAppChannel | None" = None
_sms: "SmsChannel | None" = None
# Callback für TUI-Benachrichtigungen bei neuen Nachrichten
_new_message_callbacks: list[Any] = []
def register_channels(telegram: Any, whatsapp: Any, sms: Any) -> None:
global _telegram, _whatsapp, _sms
_telegram = telegram
_whatsapp = whatsapp
_sms = sms
def add_new_message_callback(cb: Any) -> None:
_new_message_callbacks.append(cb)
# ── Senden ─────────────────────────────────────────────────────────────────────
async def send(db: Session, req: SendMessageRequest) -> Message:
# Empfänger & Konversation ermitteln
if req.channel == "telegram":
recipient_id = req.recipient_telegram_id
contact = contact_service.get_or_create_by_telegram(
db, recipient_id, name=recipient_id
)
channel_conv_id = recipient_id
else:
recipient_id = req.recipient_phone
contact = contact_service.get_or_create_by_phone(db, recipient_id)
channel_conv_id = recipient_id
conv = conversation_service.get_or_create(
db, req.channel, channel_conv_id, contact
)
# Nachricht in DB anlegen (pending)
msg = Message(
conversation_id=conv.id,
channel=req.channel,
direction="outbound",
text=req.text,
status="pending",
reply_to_id=req.reply_to_id,
)
db.add(msg)
db.commit()
db.refresh(msg)
# Kanal-spezifisches Senden
result = await _dispatch(req.channel, recipient_id, req.text, req.reply_to_id)
# Status aktualisieren
if result["success"]:
msg.status = "sent"
msg.sent_at = datetime.utcnow()
msg.channel_message_id = result.get("channel_message_id")
else:
msg.status = "failed"
msg.error_message = result.get("error")
conv.last_message_at = datetime.utcnow()
db.commit()
db.refresh(msg)
await _notify_callbacks(msg)
return msg
async def _dispatch(channel: str, recipient: str, text: str, reply_to: str | None) -> dict:
if channel == "telegram" and _telegram:
return await _telegram.send_message(recipient, text, reply_to)
if channel == "whatsapp" and _whatsapp:
return await _whatsapp.send_message(recipient, text, reply_to)
if channel == "sms" and _sms:
return await _sms.send_message(recipient, text, reply_to)
return {"success": False, "channel_message_id": None, "error": "Channel not available"}
# ── Empfangen (Inbound) ────────────────────────────────────────────────────────
async def handle_inbound(payload: dict[str, Any]) -> None:
"""Wird von den Kanal-Callbacks aufgerufen."""
db = SessionLocal()
try:
channel = payload["channel"]
if channel == "telegram":
contact = contact_service.get_or_create_by_telegram(
db,
payload["sender_telegram_id"],
name=payload.get("sender_name", "Unknown"),
)
channel_conv_id = payload["chat_id"]
else:
contact = contact_service.get_or_create_by_phone(
db,
payload.get("sender_phone", "unknown"),
name=payload.get("sender_name"),
)
channel_conv_id = payload.get("chat_id", payload.get("sender_phone", ""))
conv = conversation_service.get_or_create(
db, channel, channel_conv_id, contact, title=contact.name
)
# Duplikat-Prüfung via channel_message_id
channel_msg_id = payload.get("channel_message_id")
if channel_msg_id:
existing = (
db.query(Message)
.filter(
Message.channel == channel,
Message.channel_message_id == channel_msg_id,
)
.first()
)
if existing:
return
msg = Message(
conversation_id=conv.id,
sender_id=contact.id,
channel=channel,
channel_message_id=channel_msg_id,
direction="inbound",
text=payload.get("text", ""),
status="delivered",
delivered_at=datetime.utcnow(),
reply_to_id=payload.get("reply_to_id"),
)
db.add(msg)
conv.last_message_at = datetime.utcnow()
db.commit()
db.refresh(msg)
logger.info("[%s] Inbound from %s: %s", channel, contact.name, msg.text[:50])
await _notify_callbacks(msg)
except Exception as exc:
logger.error("handle_inbound error: %s", exc)
db.rollback()
finally:
db.close()
async def _notify_callbacks(msg: Message) -> None:
for cb in _new_message_callbacks:
try:
await cb(msg)
except Exception as exc:
logger.warning("Message callback error: %s", exc)
# ── Hilfsfunktionen ────────────────────────────────────────────────────────────
def get_messages(
db: Session, conversation_id: str, limit: int = 50, offset: int = 0
) -> list[Message]:
return conversation_service.get_messages(db, conversation_id, limit, offset)

0
tasks/__init__.py Normal file
View File

39
tasks/receiver.py Normal file
View File

@@ -0,0 +1,39 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
if TYPE_CHECKING:
from channels.whatsapp_channel import WhatsAppChannel
logger = logging.getLogger(__name__)
_scheduler: AsyncIOScheduler | None = None
_whatsapp: "WhatsAppChannel | None" = None
def build_scheduler(whatsapp: "WhatsAppChannel") -> AsyncIOScheduler:
global _scheduler, _whatsapp
_whatsapp = whatsapp
_scheduler = AsyncIOScheduler(timezone="UTC")
# WhatsApp-Polling alle 5 Sekunden
_scheduler.add_job(
_poll_whatsapp,
trigger=IntervalTrigger(seconds=5),
id="whatsapp-poll",
name="WhatsApp incoming messages",
max_instances=1,
coalesce=True,
)
return _scheduler
async def _poll_whatsapp() -> None:
if _whatsapp:
await _whatsapp.poll_incoming()

0
tui/__init__.py Normal file
View File

16
tui/app.py Normal file
View File

@@ -0,0 +1,16 @@
from __future__ import annotations
from textual.app import App
from tui.screens.main_screen import MainScreen
class MCMApp(App):
"""MCM MultiCustomerMessenger TUI."""
TITLE = "MCM MultiCustomerMessenger"
CSS_PATH = "styles.tcss"
SCREENS = {"main": MainScreen}
def on_mount(self) -> None:
self.push_screen("main")

0
tui/screens/__init__.py Normal file
View File

View File

@@ -0,0 +1,50 @@
from __future__ import annotations
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, Input, Label, Select, TextArea
CHANNEL_OPTIONS = [
("Telegram", "telegram"),
("WhatsApp", "whatsapp"),
("SMS", "sms"),
]
class ComposeScreen(ModalScreen[dict | None]):
"""Modal-Dialog für neue ausgehende Nachricht."""
DEFAULT_CSS = ""
def compose(self) -> ComposeResult:
with Vertical(id="compose-dialog"):
yield Label("Neue Nachricht", id="compose-title")
yield Label("Kanal:")
yield Select(
[(label, value) for label, value in CHANNEL_OPTIONS],
id="channel-select",
value="telegram",
)
yield Label("Empfänger (Telefon / Telegram-ID):")
yield Input(placeholder="+49… oder Telegram-Chat-ID", id="recipient-input")
yield Label("Nachricht:")
yield TextArea(id="msg-textarea")
with Horizontal(id="compose-buttons"):
yield Button("Abbrechen", variant="default", id="btn-cancel")
yield Button("Senden", variant="primary", id="btn-send")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-cancel":
self.dismiss(None)
return
channel = self.query_one("#channel-select", Select).value
recipient = self.query_one("#recipient-input", Input).value.strip()
text = self.query_one("#msg-textarea", TextArea).text.strip()
if not recipient or not text:
return
self.dismiss({"channel": channel, "recipient": recipient, "text": text})

212
tui/screens/main_screen.py Normal file
View File

@@ -0,0 +1,212 @@
from __future__ import annotations
import asyncio
from datetime import datetime
from typing import TYPE_CHECKING
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.screen import Screen
from textual.widgets import Button, DataTable, Footer, Header, Input, RichLog, Static
from db.database import SessionLocal
from schemas import SendMessageRequest
from services import conversation_service, message_service
if TYPE_CHECKING:
from db.models import Message
# Kanal-Symbole und Farben
CHANNEL_ICON = {"telegram": "", "whatsapp": "📱", "sms": ""}
CHANNEL_COLOR = {"telegram": "cyan", "whatsapp": "green", "sms": "yellow"}
class MainScreen(Screen):
BINDINGS = [
Binding("n", "new_message", "Neu"),
Binding("r", "refresh", "Aktualisieren"),
Binding("q", "quit_app", "Beenden"),
]
def __init__(self) -> None:
super().__init__()
self._current_conv_id: str | None = None
self._conv_id_map: dict[int, str] = {} # Zeilen-Index → conv_id
# ── Layout ─────────────────────────────────────────────────────────────────
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with Horizontal(id="main-container"):
# Seitenleiste
with Vertical(id="sidebar"):
yield Static("Konversationen", id="sidebar-title")
yield DataTable(id="conv-table", cursor_type="row", show_header=True)
# Chat
with Vertical(id="chat-area"):
yield Static("Kein Chat geöffnet", id="chat-header")
yield RichLog(id="message-log", highlight=True, markup=True, wrap=True)
with Horizontal(id="input-bar"):
yield Input(
placeholder="Nachricht eingeben… (Enter = Senden)",
id="msg-input",
)
yield Button("Senden", variant="primary", id="send-btn")
yield Footer()
def on_mount(self) -> None:
self._setup_table()
self._load_conversations()
# Neuer-Nachrichten-Callback registrieren
message_service.add_new_message_callback(self._on_new_message)
# ── Konversationsliste ─────────────────────────────────────────────────────
def _setup_table(self) -> None:
table = self.query_one("#conv-table", DataTable)
table.add_column("K", width=2)
table.add_column("Name", width=16)
table.add_column("Letzte Nachricht", width=22)
table.add_column("🔔", width=3)
def _load_conversations(self) -> None:
db = SessionLocal()
try:
convs = conversation_service.get_all(db)
table = self.query_one("#conv-table", DataTable)
table.clear()
self._conv_id_map.clear()
for idx, conv in enumerate(convs):
last = conv.messages[-1] if conv.messages else None
last_text = (last.text[:20] + "") if last and len(last.text) > 20 else (last.text if last else "")
icon = CHANNEL_ICON.get(conv.channel, "?")
unread = conversation_service.unread_count(db, conv.id)
badge = str(unread) if unread else ""
table.add_row(icon, conv.title or conv.id[:8], last_text, badge)
self._conv_id_map[idx] = conv.id
finally:
db.close()
# ── Nachrichten ────────────────────────────────────────────────────────────
def _load_messages(self, conv_id: str) -> None:
db = SessionLocal()
try:
conv = conversation_service.get_by_id(db, conv_id)
if not conv:
return
icon = CHANNEL_ICON.get(conv.channel, "?")
color = CHANNEL_COLOR.get(conv.channel, "white")
header = self.query_one("#chat-header", Static)
header.update(f"{icon} [{color}]{conv.title or conv.id[:8]}[/{color}] [{conv.channel}]")
log = self.query_one("#message-log", RichLog)
log.clear()
msgs = conversation_service.get_messages(db, conv_id, limit=100)
conversation_service.mark_all_read(db, conv_id)
for msg in msgs:
self._render_message(log, msg, color)
finally:
db.close()
def _render_message(self, log: RichLog, msg: "Message", channel_color: str) -> None:
ts = msg.created_at.strftime("%H:%M") if msg.created_at else "??"
direction_prefix = "" if msg.direction == "outbound" else ""
status_suffix = "" if msg.status == "failed" else ""
style = "dim" if msg.direction == "outbound" else ""
log.write(
f"[dim]{ts}[/dim] [{style}]{direction_prefix}{msg.text}{status_suffix}[/{style}]"
)
# ── Events ─────────────────────────────────────────────────────────────────
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
row_idx = event.cursor_row
conv_id = self._conv_id_map.get(row_idx)
if conv_id:
self._current_conv_id = conv_id
self._load_messages(conv_id)
# Ungelesen-Badge in Tabelle zurücksetzen
table = self.query_one("#conv-table", DataTable)
table.update_cell_at((row_idx, 3), "")
self.query_one("#msg-input", Input).focus()
def on_input_submitted(self, event: Input.Submitted) -> None:
if event.input.id == "msg-input":
self._send_current()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "send-btn":
self._send_current()
def _send_current(self) -> None:
inp = self.query_one("#msg-input", Input)
text = inp.value.strip()
if not text or not self._current_conv_id:
return
inp.clear()
asyncio.create_task(self._send_async(text))
async def _send_async(self, text: str) -> None:
db = SessionLocal()
try:
conv = conversation_service.get_by_id(db, self._current_conv_id)
if not conv:
return
req = SendMessageRequest(
channel=conv.channel,
recipient_phone=conv.channel_conversation_id if conv.channel != "telegram" else None,
recipient_telegram_id=conv.channel_conversation_id if conv.channel == "telegram" else None,
text=text,
)
await message_service.send(db, req)
self._load_messages(self._current_conv_id)
self._load_conversations()
finally:
db.close()
async def _on_new_message(self, msg: "Message") -> None:
"""Callback: neue eingehende Nachricht → TUI aktualisieren."""
self.call_from_thread(self._refresh_on_new_message, msg.conversation_id)
def _refresh_on_new_message(self, conv_id: str) -> None:
self._load_conversations()
if self._current_conv_id == conv_id:
self._load_messages(conv_id)
# ── Actions ────────────────────────────────────────────────────────────────
def action_new_message(self) -> None:
from tui.screens.compose_screen import ComposeScreen
self.app.push_screen(ComposeScreen(), self._compose_callback)
def _compose_callback(self, result: dict | None) -> None:
if not result:
return
asyncio.create_task(self._send_compose(result))
async def _send_compose(self, result: dict) -> None:
db = SessionLocal()
try:
channel = result["channel"]
recipient = result["recipient"]
req = SendMessageRequest(
channel=channel,
recipient_phone=recipient if channel != "telegram" else None,
recipient_telegram_id=recipient if channel == "telegram" else None,
text=result["text"],
)
await message_service.send(db, req)
self._load_conversations()
finally:
db.close()
def action_refresh(self) -> None:
self._load_conversations()
if self._current_conv_id:
self._load_messages(self._current_conv_id)
def action_quit_app(self) -> None:
self.app.exit()

128
tui/styles.tcss Normal file
View File

@@ -0,0 +1,128 @@
/* MCM TUI Stylesheet */
Screen {
background: $surface;
}
/* ── Haupt-Layout ─────────────────────────────────────────────────────── */
#main-container {
layout: horizontal;
height: 1fr;
}
/* ── Seitenleiste (Konversationsliste) ────────────────────────────────── */
#sidebar {
width: 28;
min-width: 22;
max-width: 40;
border-right: solid $primary-darken-1;
}
#sidebar-title {
background: $primary;
color: $text;
text-style: bold;
padding: 0 1;
height: 1;
}
#conv-list {
height: 1fr;
}
#conv-list > DataTable {
height: 1fr;
scrollbar-gutter: stable;
}
/* ── Chat-Bereich ─────────────────────────────────────────────────────── */
#chat-area {
width: 1fr;
layout: vertical;
}
#chat-header {
height: 1;
background: $primary-darken-1;
color: $text;
padding: 0 1;
text-style: bold;
}
#message-log {
height: 1fr;
border: none;
padding: 0 1;
scrollbar-gutter: stable;
}
#input-bar {
height: auto;
min-height: 3;
border-top: solid $primary-darken-1;
padding: 0 1;
layout: horizontal;
}
#msg-input {
width: 1fr;
border: none;
}
#send-btn {
width: 10;
min-width: 8;
margin-left: 1;
}
/* ── Kanal-Badges ─────────────────────────────────────────────────────── */
.badge-telegram { color: $accent; }
.badge-whatsapp { color: $success; }
.badge-sms { color: $warning; }
/* ── Nachrichten ──────────────────────────────────────────────────────── */
.msg-inbound { color: $text; }
.msg-outbound { color: $text-muted; }
.msg-failed { color: $error; }
/* ── Compose Modal ────────────────────────────────────────────────────── */
#compose-dialog {
width: 70;
height: auto;
border: thick $primary;
background: $surface;
padding: 1 2;
}
#compose-dialog Label {
margin-top: 1;
}
#compose-dialog Select {
width: 100%;
}
#compose-dialog Input {
width: 100%;
}
#compose-dialog TextArea {
width: 100%;
height: 6;
}
#compose-buttons {
layout: horizontal;
margin-top: 1;
align: right middle;
}
#compose-buttons Button {
margin-left: 1;
}

0
tui/widgets/__init__.py Normal file
View File