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:
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()
|
||||
Reference in New Issue
Block a user