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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
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 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")
|
||||||
|
|||||||
@@ -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
21
tui_standalone.py
Normal 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()
|
||||||
Reference in New Issue
Block a user