From 0e2a8a6bc09ada1ed06cdfc90cb78283ed2c0758 Mon Sep 17 00:00:00 2001 From: "itdrui.de" Date: Wed, 4 Mar 2026 20:45:55 +0100 Subject: [PATCH] feat: TUI als Browser-App via textual serve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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'. --- .env.example | 4 + config.py | 3 + requirements.txt | 1 + tui/api_client.py | 94 ++++++++++++++++ tui/app.py | 5 + tui/screens/main_screen.py | 220 +++++++++++++++++++++---------------- tui_standalone.py | 21 ++++ 7 files changed, 254 insertions(+), 94 deletions(-) create mode 100644 tui/api_client.py create mode 100644 tui_standalone.py diff --git a/.env.example b/.env.example index dbb70fc..ee1cd68 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,7 @@ SMS_ENABLED=false # ── Datenbank ────────────────────────────────────────────── 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 diff --git a/config.py b/config.py index 1768958..a71cf10 100644 --- a/config.py +++ b/config.py @@ -26,6 +26,9 @@ class Settings(BaseSettings): # Datenbank database_url: str = "sqlite:///./mcm.db" + # TUI Web-Modus + web_port: int = 8001 + @property def telegram_enabled(self) -> bool: return bool(self.telegram_token) diff --git a/requirements.txt b/requirements.txt index eb6e830..5e004d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,4 @@ python-gsmmodem-new>=0.10 apscheduler>=3.10.4 textual>=0.75.0 python-dotenv>=1.0.0 +httpx>=0.27.0 diff --git a/tui/api_client.py b/tui/api_client.py new file mode 100644 index 0000000..9fdcf7f --- /dev/null +++ b/tui/api_client.py @@ -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() diff --git a/tui/app.py b/tui/app.py index 925467a..df20a94 100644 --- a/tui/app.py +++ b/tui/app.py @@ -2,6 +2,7 @@ from __future__ import annotations from textual.app import App +from tui.api_client import MCMApiClient from tui.screens.main_screen import MainScreen @@ -12,5 +13,9 @@ class MCMApp(App): CSS_PATH = "styles.tcss" SCREENS = {"main": MainScreen} + def __init__(self) -> None: + super().__init__() + self._api_client = MCMApiClient() + def on_mount(self) -> None: self.push_screen("main") diff --git a/tui/screens/main_screen.py b/tui/screens/main_screen.py index 3cfb06c..c303583 100644 --- a/tui/screens/main_screen.py +++ b/tui/screens/main_screen.py @@ -1,23 +1,21 @@ from __future__ import annotations import asyncio -from datetime import datetime -from typing import TYPE_CHECKING +import logging +from typing import TYPE_CHECKING, Any 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 +from textual import work 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_COLOR = {"telegram": "cyan", "whatsapp": "green", "sms": "yellow"} @@ -32,18 +30,21 @@ class MainScreen(Screen): def __init__(self) -> None: super().__init__() 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 ───────────────────────────────────────────────────────────────── 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) @@ -55,11 +56,10 @@ class MainScreen(Screen): yield Button("Senden", variant="primary", id="send-btn") yield Footer() - def on_mount(self) -> None: + async 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) + await self._load_conversations() + self._poll_loop() # ── Konversationsliste ───────────────────────────────────────────────────── @@ -70,66 +70,107 @@ class MainScreen(Screen): table.add_column("Letzte Nachricht", width=22) table.add_column("🔔", width=3) - def _load_conversations(self) -> None: - db = SessionLocal() + async def _load_conversations(self) -> None: 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() + convs: list[dict] = await self._api.get_conversations() + except Exception as exc: + logger.warning("Konversationen laden fehlgeschlagen: %s", exc) + return + + table = self.query_one("#conv-table", DataTable) + table.clear() + self._conv_id_map.clear() + + for idx, conv in enumerate(convs): + last_msg = conv.get("last_message") or {} + last_text = last_msg.get("text", "") or "" + if len(last_text) > 20: + 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 ──────────────────────────────────────────────────────────── - def _load_messages(self, conv_id: str) -> None: - db = SessionLocal() + async def _load_messages(self, conv_id: str) -> None: 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}]") + conv = await self._api.get_conversation(conv_id) + msgs: list[dict] = await self._api.get_messages(conv_id, limit=100) + except Exception as exc: + logger.warning("Nachrichten laden fehlgeschlagen: %s", exc) + return - 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() + if not conv: + return - 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}]" + channel = conv.get("channel", "") + icon = CHANNEL_ICON.get(channel, "?") + color = CHANNEL_COLOR.get(channel, "white") + self.query_one("#chat-header", Static).update( + f"{icon} [{color}]{conv.get('title') or conv_id[:8]}[/{color}] [{channel}]" ) + 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 ───────────────────────────────────────────────────────────────── - 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 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), "") + try: + self._current_conv = await self._api.get_conversation(conv_id) + except Exception: + self._current_conv = None + await self._load_messages(conv_id) self.query_one("#msg-input", Input).focus() def on_input_submitted(self, event: Input.Submitted) -> None: @@ -149,37 +190,27 @@ class MainScreen(Screen): asyncio.create_task(self._send_async(text)) 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: - 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, + await self._api.send_message( + channel=channel, 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) - 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) + await self._load_messages(self._current_conv_id) + await self._load_conversations() + except Exception as exc: + logger.error("Senden fehlgeschlagen: %s", exc) # ── 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: @@ -188,25 +219,26 @@ class MainScreen(Screen): asyncio.create_task(self._send_compose(result)) async def _send_compose(self, result: dict) -> None: - db = SessionLocal() + channel = result["channel"] + recipient = result["recipient"] try: - channel = result["channel"] - recipient = result["recipient"] - req = SendMessageRequest( + await self._api.send_message( channel=channel, + text=result["text"], 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() + await self._load_conversations() + except Exception as exc: + logger.error("Senden fehlgeschlagen: %s", exc) 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: - self._load_messages(self._current_conv_id) + await self._load_messages(self._current_conv_id) def action_quit_app(self) -> None: self.app.exit() diff --git a/tui_standalone.py b/tui_standalone.py new file mode 100644 index 0000000..b1cde2e --- /dev/null +++ b/tui_standalone.py @@ -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://:8001 +""" + +from tui.app import MCMApp + +app = MCMApp() + +if __name__ == "__main__": + app.run()