commit 7f3b4768c3e51234dc2e386b8cc470eb695aeba2 Author: itdrui.de Date: Tue Mar 3 14:43:19 2026 +0100 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. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dbb70fc --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..07d0497 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app.py b/api/app.py new file mode 100644 index 0000000..c0374d8 --- /dev/null +++ b/api/app.py @@ -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()} diff --git a/api/auth.py b/api/auth.py new file mode 100644 index 0000000..6e2876b --- /dev/null +++ b/api/auth.py @@ -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 diff --git a/api/routes/__init__.py b/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/routes/channels.py b/api/routes/channels.py new file mode 100644 index 0000000..932e5fd --- /dev/null +++ b/api/routes/channels.py @@ -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()) diff --git a/api/routes/contacts.py b/api/routes/contacts.py new file mode 100644 index 0000000..2e32f1f --- /dev/null +++ b/api/routes/contacts.py @@ -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) diff --git a/api/routes/conversations.py b/api/routes/conversations.py new file mode 100644 index 0000000..7e948f9 --- /dev/null +++ b/api/routes/conversations.py @@ -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] diff --git a/api/routes/messages.py b/api/routes/messages.py new file mode 100644 index 0000000..93df7bb --- /dev/null +++ b/api/routes/messages.py @@ -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] diff --git a/channels/__init__.py b/channels/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/channels/base.py b/channels/base.py new file mode 100644 index 0000000..17980b8 --- /dev/null +++ b/channels/base.py @@ -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.""" diff --git a/channels/sms_channel.py b/channels/sms_channel.py new file mode 100644 index 0000000..1616ea2 --- /dev/null +++ b/channels/sms_channel.py @@ -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) diff --git a/channels/telegram_channel.py b/channels/telegram_channel.py new file mode 100644 index 0000000..e0726e8 --- /dev/null +++ b/channels/telegram_channel.py @@ -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) diff --git a/channels/whatsapp_channel.py b/channels/whatsapp_channel.py new file mode 100644 index 0000000..b9dd016 --- /dev/null +++ b/channels/whatsapp_channel.py @@ -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 diff --git a/config.py b/config.py new file mode 100644 index 0000000..1768958 --- /dev/null +++ b/config.py @@ -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() diff --git a/db/__init__.py b/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/db/database.py b/db/database.py new file mode 100644 index 0000000..7d8f1b9 --- /dev/null +++ b/db/database.py @@ -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) diff --git a/db/models.py b/db/models.py new file mode 100644 index 0000000..513281e --- /dev/null +++ b/db/models.py @@ -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"), + ) diff --git a/install/README_install.md b/install/README_install.md new file mode 100644 index 0000000..f0b1197 --- /dev/null +++ b/install/README_install.md @@ -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 < 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 diff --git a/main_api_only.py b/main_api_only.py new file mode 100644 index 0000000..86d23f7 --- /dev/null +++ b/main_api_only.py @@ -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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..eb6e830 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/schemas.py b/schemas.py new file mode 100644 index 0000000..1fc3265 --- /dev/null +++ b/schemas.py @@ -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 diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/contact_service.py b/services/contact_service.py new file mode 100644 index 0000000..360e4ac --- /dev/null +++ b/services/contact_service.py @@ -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 diff --git a/services/conversation_service.py b/services/conversation_service.py new file mode 100644 index 0000000..b9e947a --- /dev/null +++ b/services/conversation_service.py @@ -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() diff --git a/services/message_service.py b/services/message_service.py new file mode 100644 index 0000000..9fe0085 --- /dev/null +++ b/services/message_service.py @@ -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) diff --git a/tasks/__init__.py b/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tasks/receiver.py b/tasks/receiver.py new file mode 100644 index 0000000..4ec1ea9 --- /dev/null +++ b/tasks/receiver.py @@ -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() diff --git a/tui/__init__.py b/tui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tui/app.py b/tui/app.py new file mode 100644 index 0000000..925467a --- /dev/null +++ b/tui/app.py @@ -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") diff --git a/tui/screens/__init__.py b/tui/screens/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tui/screens/compose_screen.py b/tui/screens/compose_screen.py new file mode 100644 index 0000000..24d3bce --- /dev/null +++ b/tui/screens/compose_screen.py @@ -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}) diff --git a/tui/screens/main_screen.py b/tui/screens/main_screen.py new file mode 100644 index 0000000..3cfb06c --- /dev/null +++ b/tui/screens/main_screen.py @@ -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() diff --git a/tui/styles.tcss b/tui/styles.tcss new file mode 100644 index 0000000..6cafce7 --- /dev/null +++ b/tui/styles.tcss @@ -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; +} diff --git a/tui/widgets/__init__.py b/tui/widgets/__init__.py new file mode 100644 index 0000000..e69de29