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.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 14:43:19 +01:00
commit 7a8c8149c9
38 changed files with 2072 additions and 0 deletions

0
tui/__init__.py Normal file
View File

16
tui/app.py Normal file
View File

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

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

View File

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

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

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

128
tui/styles.tcss Normal file
View File

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

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