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:
94
tui/api_client.py
Normal file
94
tui/api_client.py
Normal 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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user