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:
24
.env.example
Normal file
24
.env.example
Normal 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
38
.gitignore
vendored
Normal 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
0
api/__init__.py
Normal file
33
api/app.py
Normal file
33
api/app.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from api.routes import channels, contacts, conversations, messages
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="MCM – MultiCustomerMessenger API",
|
||||||
|
description="Unified messaging gateway for Telegram, WhatsApp and SMS",
|
||||||
|
version="1.0.0",
|
||||||
|
docs_url="/docs",
|
||||||
|
redoc_url="/redoc",
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Routen
|
||||||
|
app.include_router(messages.router, prefix="/api/v1")
|
||||||
|
app.include_router(conversations.router, prefix="/api/v1")
|
||||||
|
app.include_router(contacts.router, prefix="/api/v1")
|
||||||
|
app.include_router(channels.router, prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health", tags=["system"])
|
||||||
|
def health():
|
||||||
|
return {"status": "ok", "service": "MCM", "timestamp": datetime.utcnow().isoformat()}
|
||||||
18
api/auth.py
Normal file
18
api/auth.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from fastapi import HTTPException, Security, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
_bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
def require_api_key(
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Security(_bearer),
|
||||||
|
) -> str:
|
||||||
|
if not credentials or credentials.credentials != settings.api_key:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or missing API key",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
return credentials.credentials
|
||||||
0
api/routes/__init__.py
Normal file
0
api/routes/__init__.py
Normal file
48
api/routes/channels.py
Normal file
48
api/routes/channels.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from api.auth import require_api_key
|
||||||
|
from config import settings
|
||||||
|
from schemas import ChannelStatusResponse, ChannelType, SystemStatusResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/channels", tags=["channels"])
|
||||||
|
|
||||||
|
# Wird in main.py gesetzt
|
||||||
|
_channel_registry: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
def register(telegram: object, whatsapp: object, sms: object) -> None:
|
||||||
|
_channel_registry["telegram"] = telegram
|
||||||
|
_channel_registry["whatsapp"] = whatsapp
|
||||||
|
_channel_registry["sms"] = sms
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status", response_model=SystemStatusResponse)
|
||||||
|
async def channel_status(_: str = Depends(require_api_key)):
|
||||||
|
statuses = []
|
||||||
|
|
||||||
|
for name, channel_type in [
|
||||||
|
("telegram", ChannelType.telegram),
|
||||||
|
("whatsapp", ChannelType.whatsapp),
|
||||||
|
("sms", ChannelType.sms),
|
||||||
|
]:
|
||||||
|
ch = _channel_registry.get(name)
|
||||||
|
if ch is None:
|
||||||
|
statuses.append(
|
||||||
|
ChannelStatusResponse(channel=channel_type, enabled=False, connected=False)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
connected, detail = await ch.check_connection()
|
||||||
|
enabled = (
|
||||||
|
settings.telegram_enabled
|
||||||
|
if name == "telegram"
|
||||||
|
else (settings.whatsapp_enabled if name == "whatsapp" else settings.sms_enabled)
|
||||||
|
)
|
||||||
|
statuses.append(
|
||||||
|
ChannelStatusResponse(
|
||||||
|
channel=channel_type, enabled=enabled, connected=connected, detail=detail
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return SystemStatusResponse(channels=statuses, database=True, timestamp=datetime.utcnow())
|
||||||
60
api/routes/contacts.py
Normal file
60
api/routes/contacts.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from api.auth import require_api_key
|
||||||
|
from db.database import get_db
|
||||||
|
from schemas import ContactCreate, ContactResponse, ContactUpdate
|
||||||
|
from services import contact_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/contacts", tags=["contacts"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[ContactResponse])
|
||||||
|
def list_contacts(db: Session = Depends(get_db), _: str = Depends(require_api_key)):
|
||||||
|
return contact_service.get_all(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=ContactResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_contact(
|
||||||
|
data: ContactCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
|
):
|
||||||
|
return contact_service.create(db, data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{contact_id}", response_model=ContactResponse)
|
||||||
|
def get_contact(
|
||||||
|
contact_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
|
):
|
||||||
|
contact = contact_service.get_by_id(db, contact_id)
|
||||||
|
if not contact:
|
||||||
|
raise HTTPException(status_code=404, detail="Contact not found")
|
||||||
|
return contact
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{contact_id}", response_model=ContactResponse)
|
||||||
|
def update_contact(
|
||||||
|
contact_id: str,
|
||||||
|
data: ContactUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
|
):
|
||||||
|
contact = contact_service.get_by_id(db, contact_id)
|
||||||
|
if not contact:
|
||||||
|
raise HTTPException(status_code=404, detail="Contact not found")
|
||||||
|
return contact_service.update(db, contact, data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_contact(
|
||||||
|
contact_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
|
):
|
||||||
|
contact = contact_service.get_by_id(db, contact_id)
|
||||||
|
if not contact:
|
||||||
|
raise HTTPException(status_code=404, detail="Contact not found")
|
||||||
|
contact_service.delete(db, contact)
|
||||||
66
api/routes/conversations.py
Normal file
66
api/routes/conversations.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from api.auth import require_api_key
|
||||||
|
from db.database import get_db
|
||||||
|
from schemas import ConversationResponse, MessageResponse
|
||||||
|
from services import conversation_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/conversations", tags=["conversations"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[ConversationResponse])
|
||||||
|
def list_conversations(
|
||||||
|
channel: str | None = Query(None),
|
||||||
|
archived: bool = Query(False),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
|
):
|
||||||
|
convs = conversation_service.get_all(db, channel=channel, archived=archived)
|
||||||
|
result = []
|
||||||
|
for conv in convs:
|
||||||
|
last_msg = conv.messages[-1] if conv.messages else None
|
||||||
|
result.append(
|
||||||
|
ConversationResponse(
|
||||||
|
**{
|
||||||
|
c.key: getattr(conv, c.key)
|
||||||
|
for c in conv.__table__.columns
|
||||||
|
},
|
||||||
|
last_message=MessageResponse.model_validate(last_msg) if last_msg else None,
|
||||||
|
unread_count=conversation_service.unread_count(db, conv.id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{conv_id}", response_model=ConversationResponse)
|
||||||
|
def get_conversation(
|
||||||
|
conv_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
|
):
|
||||||
|
conv = conversation_service.get_by_id(db, conv_id)
|
||||||
|
if not conv:
|
||||||
|
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||||
|
last_msg = conv.messages[-1] if conv.messages else None
|
||||||
|
return ConversationResponse(
|
||||||
|
**{c.key: getattr(conv, c.key) for c in conv.__table__.columns},
|
||||||
|
last_message=MessageResponse.model_validate(last_msg) if last_msg else None,
|
||||||
|
unread_count=conversation_service.unread_count(db, conv.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{conv_id}/messages", response_model=list[MessageResponse])
|
||||||
|
def get_messages(
|
||||||
|
conv_id: str,
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
|
):
|
||||||
|
conv = conversation_service.get_by_id(db, conv_id)
|
||||||
|
if not conv:
|
||||||
|
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||||
|
msgs = conversation_service.get_messages(db, conv_id, limit=limit, offset=offset)
|
||||||
|
conversation_service.mark_all_read(db, conv_id)
|
||||||
|
return [MessageResponse.model_validate(m) for m in msgs]
|
||||||
44
api/routes/messages.py
Normal file
44
api/routes/messages.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from api.auth import require_api_key
|
||||||
|
from db.database import get_db
|
||||||
|
from schemas import MessageResponse, SendMessageRequest
|
||||||
|
from services import message_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/messages", tags=["messages"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=MessageResponse, status_code=201)
|
||||||
|
async def send_message(
|
||||||
|
req: SendMessageRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
|
):
|
||||||
|
if req.channel == "telegram" and not req.recipient_telegram_id:
|
||||||
|
raise HTTPException(status_code=422, detail="recipient_telegram_id required for Telegram")
|
||||||
|
if req.channel in ("whatsapp", "sms") and not req.recipient_phone:
|
||||||
|
raise HTTPException(status_code=422, detail="recipient_phone required for WhatsApp/SMS")
|
||||||
|
|
||||||
|
msg = await message_service.send(db, req)
|
||||||
|
return MessageResponse.model_validate(msg)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[MessageResponse])
|
||||||
|
def list_messages(
|
||||||
|
conversation_id: str | None = Query(None),
|
||||||
|
channel: str | None = Query(None),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
|
):
|
||||||
|
from db.models import Message
|
||||||
|
|
||||||
|
q = db.query(Message)
|
||||||
|
if conversation_id:
|
||||||
|
q = q.filter(Message.conversation_id == conversation_id)
|
||||||
|
if channel:
|
||||||
|
q = q.filter(Message.channel == channel)
|
||||||
|
msgs = q.order_by(Message.created_at.desc()).offset(offset).limit(limit).all()
|
||||||
|
return [MessageResponse.model_validate(m) for m in msgs]
|
||||||
0
channels/__init__.py
Normal file
0
channels/__init__.py
Normal file
37
channels/base.py
Normal file
37
channels/base.py
Normal 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
97
channels/sms_channel.py
Normal 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)
|
||||||
112
channels/telegram_channel.py
Normal file
112
channels/telegram_channel.py
Normal 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)
|
||||||
133
channels/whatsapp_channel.py
Normal file
133
channels/whatsapp_channel.py
Normal 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
43
config.py
Normal 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
0
db/__init__.py
Normal file
38
db/database.py
Normal file
38
db/database.py
Normal 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
144
db/models.py
Normal 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
72
install/README_install.md
Normal 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
24
install/mcm.service
Normal 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
103
main.py
Normal 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
12
main_api_only.py
Normal 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
13
requirements.txt
Normal 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
128
schemas.py
Normal 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
0
services/__init__.py
Normal file
73
services/contact_service.py
Normal file
73
services/contact_service.py
Normal 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
|
||||||
85
services/conversation_service.py
Normal file
85
services/conversation_service.py
Normal 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
182
services/message_service.py
Normal 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
0
tasks/__init__.py
Normal file
39
tasks/receiver.py
Normal file
39
tasks/receiver.py
Normal 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
0
tui/__init__.py
Normal file
16
tui/app.py
Normal file
16
tui/app.py
Normal 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
0
tui/screens/__init__.py
Normal file
50
tui/screens/compose_screen.py
Normal file
50
tui/screens/compose_screen.py
Normal 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
212
tui/screens/main_screen.py
Normal 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
128
tui/styles.tcss
Normal 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
0
tui/widgets/__init__.py
Normal file
Reference in New Issue
Block a user