Initial MCM project: FastAPI + Textual TUI unified messenger

MultiCustomerMessenger supporting Telegram (python-telegram-bot),
WhatsApp (Green API) and SMS (python-gsmmodem-new). REST API with
Bearer-token auth, SQLAlchemy models for MariaDB, APScheduler for
background polling, and Textual TUI running in same asyncio event-loop.
This commit is contained in:
2026-03-03 14:43:19 +01:00
commit 7f3b4768c3
38 changed files with 2072 additions and 0 deletions

0
channels/__init__.py Normal file
View File

37
channels/base.py Normal file
View File

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

97
channels/sms_channel.py Normal file
View File

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

View File

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

View File

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