Files
MCM/tui/screens/main_screen.py
itdrui.de 7f3b4768c3 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.
2026-03-03 14:43:19 +01:00

213 lines
8.8 KiB
Python

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()