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.
213 lines
8.8 KiB
Python
213 lines
8.8 KiB
Python
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()
|