feat: TUI als Browser-App via textual serve

TUI von direkten Service-Importen auf httpx API-Calls umgestellt.
Neue tui/api_client.py als schlanker async HTTP-Client. Background-
Worker pollt API alle 5s für Echtzeit-Updates. Neuer tui_standalone.py
Entry-Point für 'textual serve --port 8001 tui_standalone.py'.
This commit is contained in:
2026-03-04 20:45:55 +01:00
parent 7f3b4768c3
commit 0e2a8a6bc0
7 changed files with 254 additions and 94 deletions

View File

@@ -22,3 +22,7 @@ SMS_ENABLED=false
# ── Datenbank ────────────────────────────────────────────── # ── Datenbank ──────────────────────────────────────────────
DATABASE_URL=sqlite:///./mcm.db DATABASE_URL=sqlite:///./mcm.db
# ── TUI Web-Modus (textual serve) ──────────────────────────
# Starten: .venv/bin/python -m textual serve --host 0.0.0.0 --port $WEB_PORT tui_standalone.py
WEB_PORT=8001

View File

@@ -26,6 +26,9 @@ class Settings(BaseSettings):
# Datenbank # Datenbank
database_url: str = "sqlite:///./mcm.db" database_url: str = "sqlite:///./mcm.db"
# TUI Web-Modus
web_port: int = 8001
@property @property
def telegram_enabled(self) -> bool: def telegram_enabled(self) -> bool:
return bool(self.telegram_token) return bool(self.telegram_token)

View File

@@ -11,3 +11,4 @@ python-gsmmodem-new>=0.10
apscheduler>=3.10.4 apscheduler>=3.10.4
textual>=0.75.0 textual>=0.75.0
python-dotenv>=1.0.0 python-dotenv>=1.0.0
httpx>=0.27.0

94
tui/api_client.py Normal file
View File

@@ -0,0 +1,94 @@
"""Async HTTP-Client für die MCM REST-API.
Die TUI kommuniziert ausschließlich über diesen Client mit dem Backend.
Dadurch läuft die TUI sowohl im Terminal (python main.py) als auch im
Browser (textual serve tui_standalone.py) ohne Änderung.
"""
from __future__ import annotations
import logging
from typing import Any
import httpx
from config import settings
logger = logging.getLogger(__name__)
class MCMApiClient:
"""Thin async wrapper um die MCM REST-API."""
def __init__(self) -> None:
self._base = f"http://127.0.0.1:{settings.port}/api/v1"
self._headers = {"Authorization": f"Bearer {settings.api_key}"}
# ── Konversationen ─────────────────────────────────────────────────────────
async def get_conversations(self, channel: str | None = None) -> list[dict[str, Any]]:
params: dict[str, Any] = {}
if channel:
params["channel"] = channel
return await self._get("/conversations", params=params)
async def get_conversation(self, conv_id: str) -> dict[str, Any] | None:
try:
return await self._get(f"/conversations/{conv_id}")
except httpx.HTTPStatusError:
return None
async def get_messages(
self, conv_id: str, limit: int = 100, offset: int = 0
) -> list[dict[str, Any]]:
return await self._get(
f"/conversations/{conv_id}/messages",
params={"limit": limit, "offset": offset},
)
# ── Nachrichten senden ─────────────────────────────────────────────────────
async def send_message(
self,
channel: str,
text: str,
recipient_phone: str | None = None,
recipient_telegram_id: str | None = None,
reply_to_id: str | None = None,
) -> dict[str, Any]:
body: dict[str, Any] = {"channel": channel, "text": text}
if recipient_phone:
body["recipient_phone"] = recipient_phone
if recipient_telegram_id:
body["recipient_telegram_id"] = recipient_telegram_id
if reply_to_id:
body["reply_to_id"] = reply_to_id
return await self._post("/messages", body)
# ── Kontakte ───────────────────────────────────────────────────────────────
async def get_contacts(self) -> list[dict[str, Any]]:
return await self._get("/contacts")
# ── Status ─────────────────────────────────────────────────────────────────
async def get_channel_status(self) -> dict[str, Any]:
return await self._get("/channels/status")
# ── Interne Hilfsmethoden ──────────────────────────────────────────────────
async def _get(self, path: str, params: dict | None = None) -> Any:
async with httpx.AsyncClient(timeout=5.0, follow_redirects=True) as client:
resp = await client.get(
self._base + path, headers=self._headers, params=params
)
resp.raise_for_status()
return resp.json()
async def _post(self, path: str, body: dict) -> Any:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
resp = await client.post(
self._base + path, headers=self._headers, json=body
)
resp.raise_for_status()
return resp.json()

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from textual.app import App from textual.app import App
from tui.api_client import MCMApiClient
from tui.screens.main_screen import MainScreen from tui.screens.main_screen import MainScreen
@@ -12,5 +13,9 @@ class MCMApp(App):
CSS_PATH = "styles.tcss" CSS_PATH = "styles.tcss"
SCREENS = {"main": MainScreen} SCREENS = {"main": MainScreen}
def __init__(self) -> None:
super().__init__()
self._api_client = MCMApiClient()
def on_mount(self) -> None: def on_mount(self) -> None:
self.push_screen("main") self.push_screen("main")

View File

@@ -1,23 +1,21 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from datetime import datetime import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.binding import Binding from textual.binding import Binding
from textual.containers import Horizontal, Vertical from textual.containers import Horizontal, Vertical
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Button, DataTable, Footer, Header, Input, RichLog, Static from textual.widgets import Button, DataTable, Footer, Header, Input, RichLog, Static
from textual import work
from db.database import SessionLocal
from schemas import SendMessageRequest
from services import conversation_service, message_service
if TYPE_CHECKING: if TYPE_CHECKING:
from db.models import Message from tui.api_client import MCMApiClient
logger = logging.getLogger(__name__)
# Kanal-Symbole und Farben
CHANNEL_ICON = {"telegram": "", "whatsapp": "📱", "sms": ""} CHANNEL_ICON = {"telegram": "", "whatsapp": "📱", "sms": ""}
CHANNEL_COLOR = {"telegram": "cyan", "whatsapp": "green", "sms": "yellow"} CHANNEL_COLOR = {"telegram": "cyan", "whatsapp": "green", "sms": "yellow"}
@@ -32,18 +30,21 @@ class MainScreen(Screen):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self._current_conv_id: str | None = None self._current_conv_id: str | None = None
self._conv_id_map: dict[int, str] = {} # Zeilen-Index → conv_id self._current_conv: dict[str, Any] | None = None
self._conv_id_map: dict[int, str] = {}
@property
def _api(self) -> "MCMApiClient":
return self.app._api_client # type: ignore[attr-defined]
# ── Layout ───────────────────────────────────────────────────────────────── # ── Layout ─────────────────────────────────────────────────────────────────
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header(show_clock=True) yield Header(show_clock=True)
with Horizontal(id="main-container"): with Horizontal(id="main-container"):
# Seitenleiste
with Vertical(id="sidebar"): with Vertical(id="sidebar"):
yield Static("Konversationen", id="sidebar-title") yield Static("Konversationen", id="sidebar-title")
yield DataTable(id="conv-table", cursor_type="row", show_header=True) yield DataTable(id="conv-table", cursor_type="row", show_header=True)
# Chat
with Vertical(id="chat-area"): with Vertical(id="chat-area"):
yield Static("Kein Chat geöffnet", id="chat-header") yield Static("Kein Chat geöffnet", id="chat-header")
yield RichLog(id="message-log", highlight=True, markup=True, wrap=True) yield RichLog(id="message-log", highlight=True, markup=True, wrap=True)
@@ -55,11 +56,10 @@ class MainScreen(Screen):
yield Button("Senden", variant="primary", id="send-btn") yield Button("Senden", variant="primary", id="send-btn")
yield Footer() yield Footer()
def on_mount(self) -> None: async def on_mount(self) -> None:
self._setup_table() self._setup_table()
self._load_conversations() await self._load_conversations()
# Neuer-Nachrichten-Callback registrieren self._poll_loop()
message_service.add_new_message_callback(self._on_new_message)
# ── Konversationsliste ───────────────────────────────────────────────────── # ── Konversationsliste ─────────────────────────────────────────────────────
@@ -70,66 +70,107 @@ class MainScreen(Screen):
table.add_column("Letzte Nachricht", width=22) table.add_column("Letzte Nachricht", width=22)
table.add_column("🔔", width=3) table.add_column("🔔", width=3)
def _load_conversations(self) -> None: async def _load_conversations(self) -> None:
db = SessionLocal()
try: try:
convs = conversation_service.get_all(db) convs: list[dict] = await self._api.get_conversations()
table = self.query_one("#conv-table", DataTable) except Exception as exc:
table.clear() logger.warning("Konversationen laden fehlgeschlagen: %s", exc)
self._conv_id_map.clear() return
for idx, conv in enumerate(convs):
last = conv.messages[-1] if conv.messages else None table = self.query_one("#conv-table", DataTable)
last_text = (last.text[:20] + "") if last and len(last.text) > 20 else (last.text if last else "") table.clear()
icon = CHANNEL_ICON.get(conv.channel, "?") self._conv_id_map.clear()
unread = conversation_service.unread_count(db, conv.id)
badge = str(unread) if unread else "" for idx, conv in enumerate(convs):
table.add_row(icon, conv.title or conv.id[:8], last_text, badge) last_msg = conv.get("last_message") or {}
self._conv_id_map[idx] = conv.id last_text = last_msg.get("text", "") or ""
finally: if len(last_text) > 20:
db.close() last_text = last_text[:20] + ""
icon = CHANNEL_ICON.get(conv.get("channel", ""), "?")
unread = conv.get("unread_count", 0)
badge = str(unread) if unread else ""
table.add_row(icon, conv.get("title") or conv["id"][:8], last_text, badge)
self._conv_id_map[idx] = conv["id"]
# ── Nachrichten ──────────────────────────────────────────────────────────── # ── Nachrichten ────────────────────────────────────────────────────────────
def _load_messages(self, conv_id: str) -> None: async def _load_messages(self, conv_id: str) -> None:
db = SessionLocal()
try: try:
conv = conversation_service.get_by_id(db, conv_id) conv = await self._api.get_conversation(conv_id)
if not conv: msgs: list[dict] = await self._api.get_messages(conv_id, limit=100)
return except Exception as exc:
icon = CHANNEL_ICON.get(conv.channel, "?") logger.warning("Nachrichten laden fehlgeschlagen: %s", exc)
color = CHANNEL_COLOR.get(conv.channel, "white") return
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) if not conv:
log.clear() return
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: channel = conv.get("channel", "")
ts = msg.created_at.strftime("%H:%M") if msg.created_at else "??" icon = CHANNEL_ICON.get(channel, "?")
direction_prefix = "" if msg.direction == "outbound" else "" color = CHANNEL_COLOR.get(channel, "white")
status_suffix = "" if msg.status == "failed" else "" self.query_one("#chat-header", Static).update(
style = "dim" if msg.direction == "outbound" else "" f"{icon} [{color}]{conv.get('title') or conv_id[:8]}[/{color}] [{channel}]"
log.write(
f"[dim]{ts}[/dim] [{style}]{direction_prefix}{msg.text}{status_suffix}[/{style}]"
) )
log = self.query_one("#message-log", RichLog)
log.clear()
for msg in msgs:
self._render_message(log, msg, color)
for row_idx, cid in self._conv_id_map.items():
if cid == conv_id:
self.query_one("#conv-table", DataTable).update_cell_at((row_idx, 3), "")
break
async def _load_messages_silently(self, conv_id: str) -> None:
"""Nachrichten neu laden ohne Header-Update (fuer Poll-Refresh)."""
try:
msgs: list[dict] = await self._api.get_messages(conv_id, limit=100)
except Exception:
return
conv = self._current_conv or {}
channel = conv.get("channel", "")
color = CHANNEL_COLOR.get(channel, "white")
log = self.query_one("#message-log", RichLog)
log.clear()
for msg in msgs:
self._render_message(log, msg, color)
def _render_message(self, log: RichLog, msg: dict, channel_color: str) -> None:
created = msg.get("created_at", "")
ts = created[11:16] if len(created) >= 16 else "??"
direction = msg.get("direction", "outbound")
direction_prefix = "" if direction == "outbound" else ""
status_suffix = "" if msg.get("status") == "failed" else ""
style = "dim" if direction == "outbound" else ""
text = msg.get("text", "")
log.write(
f"[dim]{ts}[/dim] [{style}]{direction_prefix}{text}{status_suffix}[/{style}]"
)
# ── Background-Polling ─────────────────────────────────────────────────────
@work(exclusive=True)
async def _poll_loop(self) -> None:
"""Alle 5s Konversationen und ggf. aktiven Chat aktualisieren."""
while True:
await asyncio.sleep(5)
await self._load_conversations()
if self._current_conv_id:
await self._load_messages_silently(self._current_conv_id)
# ── Events ───────────────────────────────────────────────────────────────── # ── Events ─────────────────────────────────────────────────────────────────
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: async def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
row_idx = event.cursor_row row_idx = event.cursor_row
conv_id = self._conv_id_map.get(row_idx) conv_id = self._conv_id_map.get(row_idx)
if conv_id: if conv_id:
self._current_conv_id = conv_id self._current_conv_id = conv_id
self._load_messages(conv_id) try:
# Ungelesen-Badge in Tabelle zurücksetzen self._current_conv = await self._api.get_conversation(conv_id)
table = self.query_one("#conv-table", DataTable) except Exception:
table.update_cell_at((row_idx, 3), "") self._current_conv = None
await self._load_messages(conv_id)
self.query_one("#msg-input", Input).focus() self.query_one("#msg-input", Input).focus()
def on_input_submitted(self, event: Input.Submitted) -> None: def on_input_submitted(self, event: Input.Submitted) -> None:
@@ -149,37 +190,27 @@ class MainScreen(Screen):
asyncio.create_task(self._send_async(text)) asyncio.create_task(self._send_async(text))
async def _send_async(self, text: str) -> None: async def _send_async(self, text: str) -> None:
db = SessionLocal() conv = self._current_conv
if not conv:
return
channel = conv.get("channel", "")
chat_id = conv.get("channel_conversation_id", "")
try: try:
conv = conversation_service.get_by_id(db, self._current_conv_id) await self._api.send_message(
if not conv: channel=channel,
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, text=text,
recipient_phone=chat_id if channel != "telegram" else None,
recipient_telegram_id=chat_id if channel == "telegram" else None,
) )
await message_service.send(db, req) await self._load_messages(self._current_conv_id)
self._load_messages(self._current_conv_id) await self._load_conversations()
self._load_conversations() except Exception as exc:
finally: logger.error("Senden fehlgeschlagen: %s", exc)
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 ──────────────────────────────────────────────────────────────── # ── Actions ────────────────────────────────────────────────────────────────
def action_new_message(self) -> None: def action_new_message(self) -> None:
from tui.screens.compose_screen import ComposeScreen from tui.screens.compose_screen import ComposeScreen
self.app.push_screen(ComposeScreen(), self._compose_callback) self.app.push_screen(ComposeScreen(), self._compose_callback)
def _compose_callback(self, result: dict | None) -> None: def _compose_callback(self, result: dict | None) -> None:
@@ -188,25 +219,26 @@ class MainScreen(Screen):
asyncio.create_task(self._send_compose(result)) asyncio.create_task(self._send_compose(result))
async def _send_compose(self, result: dict) -> None: async def _send_compose(self, result: dict) -> None:
db = SessionLocal() channel = result["channel"]
recipient = result["recipient"]
try: try:
channel = result["channel"] await self._api.send_message(
recipient = result["recipient"]
req = SendMessageRequest(
channel=channel, channel=channel,
text=result["text"],
recipient_phone=recipient if channel != "telegram" else None, recipient_phone=recipient if channel != "telegram" else None,
recipient_telegram_id=recipient if channel == "telegram" else None, recipient_telegram_id=recipient if channel == "telegram" else None,
text=result["text"],
) )
await message_service.send(db, req) await self._load_conversations()
self._load_conversations() except Exception as exc:
finally: logger.error("Senden fehlgeschlagen: %s", exc)
db.close()
def action_refresh(self) -> None: def action_refresh(self) -> None:
self._load_conversations() asyncio.create_task(self._do_refresh())
async def _do_refresh(self) -> None:
await self._load_conversations()
if self._current_conv_id: if self._current_conv_id:
self._load_messages(self._current_conv_id) await self._load_messages(self._current_conv_id)
def action_quit_app(self) -> None: def action_quit_app(self) -> None:
self.app.exit() self.app.exit()

21
tui_standalone.py Normal file
View File

@@ -0,0 +1,21 @@
"""MCM TUI Standalone Entry Point für textual serve.
Startet nur die TUI (kein API-Server, keine Channels).
Die TUI spricht via HTTP gegen den laufenden MCM-API-Server.
Verwendung:
# API-Server muss bereits laufen:
python main_api_only.py
# TUI im Browser starten:
.venv/bin/python -m textual serve --host 0.0.0.0 --port 8001 tui_standalone.py
# Dann im Browser öffnen: http://<host>:8001
"""
from tui.app import MCMApp
app = MCMApp()
if __name__ == "__main__":
app.run()