Compare commits

..

19 Commits

Author SHA1 Message Date
0dc718f541 fix: WhatsApp-Polling als eigene Schleife statt APScheduler (kein Overlap mehr) 2026-04-02 13:11:52 +02:00
e9f3d28554 feat: start.sh mit IP-Ausgabe, TELEGRAM_BOT_USERNAME in config, Telegram-Task sauber canceln 2026-04-02 13:06:12 +02:00
28d3b36b78 feat: QR-Code für alle Kanäle (contact_id in ConversationResponse) 2026-03-17 15:19:40 +01:00
7c237836e8 fix: _current_conv bei RowHighlighted asynchron laden (QR + Delete) 2026-03-17 15:11:21 +01:00
2b15a99dc4 fix: conv_id bei Cursor-Bewegung sofort setzen (RowHighlighted) 2026-03-17 15:03:22 +01:00
5f9d6b327b fix: Telegram-Polling-Task sauber canceln + WhatsApp CancelledError beim Shutdown unterdrücken 2026-03-17 15:01:38 +01:00
b880cabcde fix: WhatsApp channel_conv_id als nummer@c.us normalisieren beim Senden 2026-03-17 14:57:24 +01:00
2324ffd714 fix: ModalScreen Import in main_screen.py 2026-03-13 15:05:46 +01:00
7a008470ef fix: WhatsApp @c.us Doppel-Suffix + Chat löschen in TUI
- WhatsApp send: @c.us wird vor dem Anhängen entfernt (verhindert 4915..@c.us@c.us)
- DELETE /api/v1/conversations/{id}: löscht Konversation + alle Nachrichten
- TUI: Taste D öffnet Bestätigungsdialog zum Löschen des aktuellen Chats
2026-03-13 15:04:15 +01:00
b0c6ba44de fix: Rich-Markup-Fehler bei leeren Styles + Telefonnummer-Normalisierung
- _render_message: leeren style-Tag vermieden (MarkupError bei inbound-Msgs)
- Nachrichtentext: eckige Klammern werden escaped (kein Markup-Injection)
- get_by_phone: sucht +49xxx und 49xxx gleichzeitig (Green API liefert ohne +)
2026-03-13 14:58:04 +01:00
0f73341c8b fix: WhatsApp incomingWebhook beim Start automatisch aktivieren
Green-API-Instanz hatte incomingWebhook=no → keine Nachrichten in Queue.
_ensure_incoming_enabled() setzt incomingWebhook+outgoingWebhook=yes beim Start.
2026-03-13 14:49:09 +01:00
18ad0735ef feat: Telegram QR-Code Invite-Link + WhatsApp Empfang-Fix
Telegram:
- /start <contact_id> Deep-Link Handler: verknüpft Kontakt automatisch mit chat_id
- QR-Code Endpunkt GET /api/v1/contacts/{id}/telegram-qr (PNG)
- TUI: Taste T öffnet QR-Code im Browser (HTML mit eingebettetem PNG)
- config.py + .env.example: TELEGRAM_BOT_USERNAME=mcm_bot
- qrcode[pil] zu requirements.txt hinzugefügt

WhatsApp:
- receiveTimeout 5→3s, HTTP-Timeout 8s → verhindert Polling-Overlap
2026-03-13 14:45:06 +01:00
73619fbc9c feat: /todo command, breitere Sidebar, TODO.md gitignored
- Neuer Claude-Code Slash-Command /todo zur Pflege einer lokalen TODO-Liste
- TODO.md in .gitignore aufgenommen (bleibt lokal)
- TUI-Sidebar breiter: width 28→40 (min 22→30, max 40→55)
2026-03-13 14:24:53 +01:00
d7887ae0c9 fix: WebSocket-URL in Browser nutzt echte IP statt 0.0.0.0 2026-03-13 14:19:56 +01:00
23fb37cb1a fix: textual-serve als Web-Frontend statt python -m textual serve
textual serve (python -m textual) startete immer die eingebaute Demo.
Lösung: textual-serve Paket (v1.1.3) mit eigenem serve_tui.py Einstiegspunkt.
Pro Browser-Verbindung wird tui_standalone.py als eigener Subprocess gestartet.

Starten: python serve_tui.py --host 0.0.0.0 --port 8001
2026-03-13 14:12:25 +01:00
3177146267 fix: tui_standalone.py gibt Klasse statt Instanz zurück (textual serve erwartet Factory) 2026-03-13 14:06:07 +01:00
9046708015 fix: sauberer Shutdown (Telegram Updater, CancelledError), WhatsApp-Polling auf 5s 2026-03-13 14:02:45 +01:00
d67479309d fix: Telegram idle() entfernt (PTB v21), WhatsApp-Polling-Intervall auf 10s erhöht 2026-03-13 13:59:17 +01:00
4c45c8b17b fix: Telegram-Filter für python-telegram-bot v21 korrigiert 2026-03-13 13:57:01 +01:00
22 changed files with 535 additions and 75 deletions

28
.claude/commands/todo.md Normal file
View File

@@ -0,0 +1,28 @@
Verwalte die TODO-Liste des MCM-Projekts (`TODO.md` im Projektroot).
**Argument:** `$ARGUMENTS`
**Verhalten je nach Argument:**
- Kein Argument oder `list`: Zeige alle offenen und erledigten Einträge aus `TODO.md` an.
- `done <nummer>`: Markiere Eintrag Nummer `<nummer>` als erledigt (✅).
- `commit`: Erstelle für jeden offenen Eintrag (☐) einen Git-Commit-Request. Fasse zusammenhängende Einträge zu einem Commit zusammen. Zeige dem Benutzer die geplanten Commits zur Bestätigung an.
- Alles andere: Füge den Text als neuen offenen Eintrag (☐) in `TODO.md` ein.
**Format von `TODO.md`:**
```
# MCM TODO
## Offen
- [ ] Eintrag 1
- [ ] Eintrag 2
## Erledigt
- [x] Fertiger Eintrag
```
**Schritte:**
1. Lies `TODO.md` (erstelle sie falls nicht vorhanden).
2. Führe die gewünschte Aktion durch (hinzufügen / auflisten / als erledigt markieren / commit vorbereiten).
3. Schreibe `TODO.md` zurück.
4. Bestätige die Aktion kurz.

View File

@@ -9,6 +9,8 @@ DEBUG=false
# ── Telegram ───────────────────────────────────────────────
TELEGRAM_TOKEN=
# Bot-Username ohne @ (für QR-Code / Invite-Links)
TELEGRAM_BOT_USERNAME=mcm_bot
# ── WhatsApp (Green API) ───────────────────────────────────
# Konto anlegen unter: https://console.green-api.com
@@ -24,7 +26,7 @@ SMS_ENABLED=false
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
# Starten: python serve_tui.py --host 0.0.0.0 --port $WEB_PORT
WEB_PORT=8001
# ── Benutzer-Authentifizierung ──────────────────────────────────────────────

3
.gitignore vendored
View File

@@ -36,3 +36,6 @@ logs/
# OS
.DS_Store
Thumbs.db
# Todo-Liste (lokal, nicht im Repo)
TODO.md

View File

@@ -128,7 +128,7 @@ DEFAULT_ADMIN_PASSWORD=admin
.venv/bin/python main_api_only.py
# TUI als Browser-App (Terminal 2)
.venv/bin/python -m textual serve --host 0.0.0.0 --port 8001 tui_standalone.py
python serve_tui.py
```
Dann im Browser öffnen: `http://<raspberry-ip>:8001`

View File

@@ -1,7 +1,12 @@
import io
import qrcode
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from api.auth import require_api_key
from config import settings
from db.database import get_db
from schemas import ContactCreate, ContactResponse, ContactUpdate
from services import contact_service
@@ -58,3 +63,28 @@ def delete_contact(
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
contact_service.delete(db, contact)
@router.get("/{contact_id}/telegram-qr", tags=["contacts"])
def telegram_qr(
contact_id: str,
db: Session = Depends(get_db),
_: str = Depends(require_api_key),
):
"""QR-Code als PNG: Invite-Link für Telegram-Bot mit Kontakt-ID als Deep-Link-Parameter."""
contact = contact_service.get_by_id(db, contact_id)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
bot_username = settings.telegram_bot_username.lstrip("@")
invite_url = f"https://t.me/{bot_username}?start={contact_id}"
img = qrcode.make(invite_url)
buf = io.BytesIO()
img.save(buf, format="PNG")
buf.seek(0)
return StreamingResponse(
buf,
media_type="image/png",
headers={"Content-Disposition": f'attachment; filename="telegram_qr_{contact_id}.png"'},
)

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from api.auth import require_api_key
@@ -20,14 +20,13 @@ def list_conversations(
result = []
for conv in convs:
last_msg = conv.messages[-1] if conv.messages else None
contact_id = conv.participants[0].id if conv.participants else None
result.append(
ConversationResponse(
**{
c.key: getattr(conv, c.key)
for c in conv.__table__.columns
},
**{c.key: getattr(conv, c.key) for c in conv.__table__.columns},
last_message=MessageResponse.model_validate(last_msg) if last_msg else None,
unread_count=conversation_service.unread_count(db, conv.id),
contact_id=contact_id,
)
)
return result
@@ -43,10 +42,12 @@ def get_conversation(
if not conv:
raise HTTPException(status_code=404, detail="Conversation not found")
last_msg = conv.messages[-1] if conv.messages else None
contact_id = conv.participants[0].id if conv.participants else None
return ConversationResponse(
**{c.key: getattr(conv, c.key) for c in conv.__table__.columns},
last_message=MessageResponse.model_validate(last_msg) if last_msg else None,
unread_count=conversation_service.unread_count(db, conv.id),
contact_id=contact_id,
)
@@ -64,3 +65,15 @@ def get_messages(
msgs = conversation_service.get_messages(db, conv_id, limit=limit, offset=offset)
conversation_service.mark_all_read(db, conv_id)
return [MessageResponse.model_validate(m) for m in msgs]
@router.delete("/{conv_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_conversation(
conv_id: str,
db: Session = Depends(get_db),
_: str = Depends(require_api_key),
):
conv = conversation_service.get_by_id(db, conv_id)
if not conv:
raise HTTPException(status_code=404, detail="Conversation not found")
conversation_service.delete(db, conv)

View File

@@ -5,7 +5,7 @@ import logging
from typing import TYPE_CHECKING, Any, Callable, Awaitable
from telegram import Update
from telegram.ext import Application, ApplicationBuilder, MessageHandler, filters
from telegram.ext import Application, ApplicationBuilder, CommandHandler, MessageHandler, filters
from channels.base import BaseChannel
from config import settings
@@ -21,6 +21,7 @@ class TelegramChannel(BaseChannel):
def __init__(self) -> None:
self._app: Application | None = None
self._polling_task: asyncio.Task | None = None
self._inbound_callback: Callable[[dict[str, Any]], Awaitable[None]] | None = None
def set_inbound_callback(self, cb: Callable[[dict[str, Any]], Awaitable[None]]) -> None:
@@ -62,17 +63,31 @@ class TelegramChannel(BaseChannel):
return
self._app = ApplicationBuilder().token(settings.telegram_token).build()
self._app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self._handle_message))
self._app.add_handler(MessageHandler(filters.PHOTO | filters.DOCUMENT | filters.VOICE, self._handle_message))
self._app.add_handler(CommandHandler("start", self._handle_start))
self._app.add_handler(MessageHandler(~filters.COMMAND, self._handle_message))
await self._app.initialize()
await self._app.start()
# Long-Polling als Background-Task
asyncio.create_task(self._polling_loop(), name="telegram-polling")
# Long-Polling als Background-Task (Referenz speichern für sauberes Cancel)
self._polling_task = asyncio.create_task(self._polling_loop(), name="telegram-polling")
logger.info("Telegram channel started (long-polling)")
async def stop(self) -> None:
# Polling-Task sauber canceln
if self._polling_task and not self._polling_task.done():
self._polling_task.cancel()
try:
await self._polling_task
except asyncio.CancelledError:
pass
self._polling_task = None
if self._app:
try:
if self._app.updater.running:
await self._app.updater.stop()
except Exception:
pass
await self._app.stop()
await self._app.shutdown()
logger.info("Telegram channel stopped")
@@ -83,13 +98,39 @@ class TelegramChannel(BaseChannel):
"""Endlos-Polling im Hintergrund."""
try:
await self._app.updater.start_polling(allowed_updates=["message"])
# Warte bis gestoppt
await self._app.updater.idle()
# In PTB v20+ läuft der Updater als eigener asyncio-Task weiter
# wir warten hier einfach, bis der Task abgebrochen wird.
await asyncio.Event().wait()
except asyncio.CancelledError:
pass
except Exception as exc:
logger.error("Telegram polling error: %s", exc)
async def _handle_start(self, update: Update, context: Any) -> None:
"""Verarbeitet /start [contact_id] — verknüpft Kontakt mit chat_id."""
if not update.message or not self._inbound_callback:
return
msg = update.message
args = context.args # Liste der Parameter nach /start
contact_id = args[0] if args else None
payload: dict[str, Any] = {
"channel": "telegram",
"channel_message_id": str(msg.message_id),
"sender_telegram_id": str(msg.from_user.id) if msg.from_user else None,
"sender_name": (
(msg.from_user.full_name or msg.from_user.username)
if msg.from_user
else "Unknown"
),
"chat_id": str(msg.chat.id),
"text": "/start",
"reply_to_id": None,
"link_contact_id": contact_id, # Kontakt aus QR-Code verknüpfen
}
await self._inbound_callback(payload)
await msg.reply_text("Willkommen! Sie sind jetzt mit MCM verbunden.")
async def _handle_message(self, update: Update, context: Any) -> None:
if not update.message or not self._inbound_callback:
return

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import logging
from typing import Any, Callable, Awaitable
@@ -20,6 +21,7 @@ class WhatsAppChannel(BaseChannel):
)
self._token = settings.whatsapp_api_token
self._session: aiohttp.ClientSession | None = None
self._polling_task: asyncio.Task | None = None
self._inbound_callback: Callable[[dict[str, Any]], Awaitable[None]] | None = None
def set_inbound_callback(self, cb: Callable[[dict[str, Any]], Awaitable[None]]) -> None:
@@ -37,7 +39,7 @@ class WhatsAppChannel(BaseChannel):
return {"success": False, "channel_message_id": None, "error": "WhatsApp not configured"}
# chatId: Telefonnummer ohne + gefolgt von @c.us, z.B. "4917612345678@c.us"
chat_id = recipient.lstrip("+") + "@c.us"
chat_id = recipient.replace("@c.us", "").lstrip("+") + "@c.us"
url = f"{self._base_url}/sendMessage/{self._token}"
body: dict[str, Any] = {"chatId": chat_id, "message": text}
if reply_to_id:
@@ -70,34 +72,46 @@ class WhatsAppChannel(BaseChannel):
logger.info("WhatsApp disabled (no credentials configured)")
return
self._session = aiohttp.ClientSession()
await self._ensure_incoming_enabled()
self._polling_task = asyncio.create_task(self._polling_loop(), name="whatsapp-polling")
logger.info("WhatsApp channel started")
async def stop(self) -> None:
if self._polling_task and not self._polling_task.done():
self._polling_task.cancel()
try:
await self._polling_task
except asyncio.CancelledError:
pass
self._polling_task = None
if self._session and not self._session.closed:
await self._session.close()
logger.info("WhatsApp channel stopped")
# ── Polling (wird vom Scheduler aufgerufen) ────────────────────────────────
# ── Polling-Schleife ───────────────────────────────────────────────────────
async def poll_incoming(self) -> None:
"""Eingehende Nachrichten per Polling abrufen (Green API Notification Queue)."""
if not settings.whatsapp_enabled:
return
async def _polling_loop(self) -> None:
"""Endlos-Polling: direkt nach Antwort wieder starten, kein Overlap möglich."""
url = f"{self._base_url}/receiveNotification/{self._token}"
try:
async with self._get_session().get(url, params={"receiveTimeout": 5}) as resp:
if resp.status != 200:
return
data = await resp.json()
if not data:
return
receipt_id = data.get("receiptId")
body = data.get("body", {})
await self._process_notification(body)
if receipt_id:
await self._delete_notification(receipt_id)
except Exception as exc:
logger.error("WhatsApp poll error: %s", exc)
timeout = aiohttp.ClientTimeout(total=15)
while True:
try:
async with self._get_session().get(
url, params={"receiveTimeout": 5}, timeout=timeout
) as resp:
if resp.status == 200:
data = await resp.json()
if data:
receipt_id = data.get("receiptId")
body = data.get("body", {})
await self._process_notification(body)
if receipt_id:
await self._delete_notification(receipt_id)
except asyncio.CancelledError:
break
except Exception as exc:
logger.error("WhatsApp poll error: %s", exc)
await asyncio.sleep(5)
async def _delete_notification(self, receipt_id: int) -> None:
url = f"{self._base_url}/deleteNotification/{self._token}/{receipt_id}"
@@ -127,6 +141,24 @@ class WhatsAppChannel(BaseChannel):
}
await self._inbound_callback(payload)
async def _ensure_incoming_enabled(self) -> None:
"""Stellt sicher dass incomingWebhook in der Green-API-Instanz aktiviert ist."""
url = f"{self._base_url}/setSettings/{self._token}"
try:
async with self._get_session().post(
url,
json={"incomingWebhook": "yes", "outgoingWebhook": "yes"},
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
data = await resp.json()
if data.get("saveSettings"):
logger.info("WhatsApp: incomingWebhook aktiviert")
else:
logger.warning("WhatsApp: setSettings Antwort: %s", data)
except Exception as exc:
logger.warning("WhatsApp: incomingWebhook konnte nicht gesetzt werden: %s", exc)
def _get_session(self) -> aiohttp.ClientSession:
if not self._session or self._session.closed:
self._session = aiohttp.ClientSession()

View File

@@ -13,6 +13,7 @@ class Settings(BaseSettings):
# Telegram
telegram_token: str = ""
telegram_bot_username: str = "mcm_bot"
# WhatsApp (Green API)
whatsapp_id_instance: str = ""

20
main.py
View File

@@ -20,7 +20,6 @@ from channels.whatsapp_channel import WhatsAppChannel
from config import settings
from db.database import init_db
from services import message_service
from tasks.receiver import build_scheduler
logging.basicConfig(
level=logging.INFO,
@@ -67,11 +66,7 @@ async def main(with_tui: bool = True) -> None:
await whatsapp.start()
await sms.start()
# 6. Hintergrund-Tasks starten (WhatsApp-Polling etc.)
scheduler = build_scheduler(whatsapp)
scheduler.start()
# 7. Uvicorn als Hintergrund-Task starten
# 6. Uvicorn als Hintergrund-Task starten
api_task = asyncio.create_task(_run_api(), name="mcm-api")
logger.info("API running on http://%s:%d", settings.host, settings.port)
@@ -85,14 +80,21 @@ async def main(with_tui: bool = True) -> None:
else:
# 8b. Nur API wartet auf Ctrl-C / SIGTERM
logger.info("Running in API-only mode (no TUI)")
await api_task
try:
await api_task
except asyncio.CancelledError:
pass
finally:
logger.info("MCM shutting down…")
scheduler.shutdown(wait=False)
api_task.cancel()
try:
await api_task
except asyncio.CancelledError:
pass
await telegram.stop()
await whatsapp.stop()
await sms.stop()
api_task.cancel()
logger.info("MCM stopped.")
if __name__ == "__main__":

View File

@@ -14,3 +14,5 @@ python-dotenv>=1.0.0
httpx>=0.27.0
python-jose[cryptography]>=3.3.0
bcrypt>=4.0.0
textual-serve>=1.1.0
qrcode[pil]>=7.4.2

View File

@@ -111,6 +111,7 @@ class ConversationResponse(BaseModel):
created_at: datetime
last_message: Optional[MessageResponse] = None
unread_count: int = 0
contact_id: Optional[str] = None
# ── Channel Status ─────────────────────────────────────────────────────────────

73
serve_tui.py Normal file
View File

@@ -0,0 +1,73 @@
"""MCM TUI Web-Server via textual-serve.
Startet einen Web-Server, der die TUI pro Browser-Verbindung als Subprocess ausführt.
Voraussetzung: MCM API-Server muss bereits laufen (python main_api_only.py).
Verwendung:
# Lokal:
python serve_tui.py
# Raspberry Pi / entfernter Rechner:
python serve_tui.py --host 0.0.0.0 --public-host 192.168.1.100 --port 8001
# Browser: http://192.168.1.100:8001
"""
import asyncio
import socket
import sys
from textual_serve.server import Server
from config import settings
def _local_ip() -> str:
"""Ermittelt die lokale IP-Adresse (für den public_url-Fallback)."""
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
except Exception:
return "localhost"
def main() -> None:
import argparse
parser = argparse.ArgumentParser(description="MCM TUI Web-Server")
parser.add_argument("--host", default="0.0.0.0", help="Bind-Adresse (default: 0.0.0.0)")
parser.add_argument(
"--port", type=int, default=settings.web_port,
help=f"Port (default: {settings.web_port})"
)
parser.add_argument(
"--public-host", default=None,
help="Öffentlicher Hostname/IP für WebSocket-URL (default: automatisch ermittelt)"
)
args = parser.parse_args()
# Öffentliche URL für die WebSocket-Verbindung im Browser
public_host = args.public_host or (
"localhost" if args.host in ("localhost", "127.0.0.1") else _local_ip()
)
public_url = f"http://{public_host}:{args.port}"
python = sys.executable
command = f"{python} tui_standalone.py"
print(f"MCM TUI Web-Server lauscht auf {args.host}:{args.port}")
print(f"Browser-URL: {public_url}")
print("Ctrl+C zum Beenden.")
server = Server(
command=command,
host=args.host,
port=args.port,
title="MCM MultiCustomerMessenger",
public_url=public_url,
)
asyncio.run(server.serve())
if __name__ == "__main__":
main()

View File

@@ -19,11 +19,14 @@ def get_by_telegram_id(db: Session, telegram_id: str) -> Contact | None:
def get_by_phone(db: Session, phone: str) -> Contact | None:
return (
db.query(Contact)
.filter((Contact.phone == phone) | (Contact.whatsapp_phone == phone))
.first()
)
# Normalisierung: mit und ohne führendes + suchen
variants = {phone, "+" + phone.lstrip("+"), phone.lstrip("+")}
from sqlalchemy import or_
conditions = or_(*(
(Contact.phone == v) | (Contact.whatsapp_phone == v)
for v in variants
))
return db.query(Contact).filter(conditions).first()
def create(db: Session, data: ContactCreate) -> Contact:

View File

@@ -75,6 +75,13 @@ def unread_count(db: Session, conv_id: str) -> int:
)
def delete(db: Session, conv: Conversation) -> None:
"""Löscht eine Konversation inkl. aller Nachrichten."""
db.query(Message).filter(Message.conversation_id == conv.id).delete()
db.delete(conv)
db.commit()
def mark_all_read(db: Session, conv_id: str) -> None:
now = datetime.utcnow()
db.query(Message).filter(

View File

@@ -51,7 +51,11 @@ async def send(db: Session, req: SendMessageRequest) -> Message:
else:
recipient_id = req.recipient_phone
contact = contact_service.get_or_create_by_phone(db, recipient_id)
channel_conv_id = recipient_id
# WhatsApp: channel_conv_id immer als "nummer@c.us" normalisieren
if req.channel == "whatsapp":
channel_conv_id = recipient_id.replace("@c.us", "").lstrip("+") + "@c.us"
else:
channel_conv_id = recipient_id
conv = conversation_service.get_or_create(
db, req.channel, channel_conv_id, contact
@@ -109,11 +113,29 @@ async def handle_inbound(payload: dict[str, Any]) -> None:
channel = payload["channel"]
if channel == "telegram":
contact = contact_service.get_or_create_by_telegram(
db,
payload["sender_telegram_id"],
name=payload.get("sender_name", "Unknown"),
)
link_contact_id = payload.get("link_contact_id")
if link_contact_id:
# Kontakt aus QR-Code: telegram_id verknüpfen
existing = contact_service.get_by_id(db, link_contact_id)
if existing:
from schemas import ContactUpdate
contact_service.update(
db, existing,
ContactUpdate(telegram_id=payload["sender_telegram_id"])
)
contact = existing
else:
contact = contact_service.get_or_create_by_telegram(
db,
payload["sender_telegram_id"],
name=payload.get("sender_name", "Unknown"),
)
else:
contact = contact_service.get_or_create_by_telegram(
db,
payload["sender_telegram_id"],
name=payload.get("sender_name", "Unknown"),
)
channel_conv_id = payload["chat_id"]
else:
contact = contact_service.get_or_create_by_phone(

63
start.sh Executable file
View File

@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# MCM MultiCustomerMessenger Startskript
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# Virtuelle Umgebung
PYTHON=".venv/bin/python"
if [ ! -x "$PYTHON" ]; then
echo "Fehler: .venv nicht gefunden. Bitte erst 'python -m venv .venv && .venv/bin/pip install -r requirements.txt' ausführen."
exit 1
fi
# Lokale IP ermitteln
LOCAL_IP=$(python3 -c "
import socket
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(('8.8.8.8', 80))
print(s.getsockname()[0])
s.close()
except Exception:
print('localhost')
")
# Ports aus .env lesen (Fallback auf Standardwerte)
API_PORT=$(grep -E '^PORT=' .env 2>/dev/null | cut -d= -f2 | tr -d '[:space:]')
WEB_PORT=$(grep -E '^WEB_PORT=' .env 2>/dev/null | cut -d= -f2 | tr -d '[:space:]')
API_PORT=${API_PORT:-8000}
WEB_PORT=${WEB_PORT:-8001}
echo ""
echo "╔══════════════════════════════════════════════════╗"
echo "║ MCM MultiCustomerMessenger ║"
echo "╠══════════════════════════════════════════════════╣"
echo "║ REST-API : http://${LOCAL_IP}:${API_PORT} "
echo "║ Web-TUI : http://${LOCAL_IP}:${WEB_PORT} "
echo "╚══════════════════════════════════════════════════╝"
echo ""
# Web-TUI im Hintergrund starten
echo "[1/2] Starte Web-TUI (Port $WEB_PORT)..."
"$PYTHON" serve_tui.py --host 0.0.0.0 --port "$WEB_PORT" &
TUI_PID=$!
# Kurz warten damit die TUI hochfährt
sleep 1
# API starten (Vordergrund)
echo "[2/2] Starte API (Port $API_PORT)..."
"$PYTHON" main_api_only.py &
API_PID=$!
echo ""
echo "Beide Dienste gestartet. Ctrl+C zum Beenden."
echo ""
# Auf Ctrl+C warten und beide Prozesse beenden
trap "echo ''; echo 'MCM wird beendet...'; kill $TUI_PID $API_PID 2>/dev/null; wait $TUI_PID $API_PID 2>/dev/null; echo 'Beendet.'" INT TERM
wait $API_PID

View File

@@ -21,7 +21,7 @@ def build_scheduler(whatsapp: "WhatsAppChannel") -> AsyncIOScheduler:
_scheduler = AsyncIOScheduler(timezone="UTC")
# WhatsApp-Polling alle 5 Sekunden
# WhatsApp-Polling alle 5 Sekunden (Green API erlaubt 100 req/s)
_scheduler.add_job(
_poll_whatsapp,
trigger=IntervalTrigger(seconds=5),
@@ -29,6 +29,7 @@ def build_scheduler(whatsapp: "WhatsAppChannel") -> AsyncIOScheduler:
name="WhatsApp incoming messages",
max_instances=1,
coalesce=True,
misfire_grace_time=5,
)
return _scheduler

View File

@@ -101,6 +101,24 @@ class MCMApiClient:
async def get_contacts(self) -> list[dict[str, Any]]:
return await self._get("/contacts")
async def delete_conversation(self, conv_id: str) -> None:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
resp = await client.delete(
self._base + f"/conversations/{conv_id}",
headers=self._headers,
)
resp.raise_for_status()
async def get_telegram_qr(self, contact_id: str) -> bytes:
"""QR-Code PNG-Bytes für einen Kontakt abrufen."""
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
resp = await client.get(
self._base + f"/contacts/{contact_id}/telegram-qr",
headers=self._headers,
)
resp.raise_for_status()
return resp.content
# ── Status ─────────────────────────────────────────────────────────────────
async def get_channel_status(self) -> dict[str, Any]:

View File

@@ -7,7 +7,7 @@ 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.screen import ModalScreen, Screen
from textual.widgets import Button, DataTable, Footer, Header, Input, RichLog, Static
from textual import work
@@ -24,6 +24,8 @@ class MainScreen(Screen):
BINDINGS = [
Binding("n", "new_message", "Neu"),
Binding("r", "refresh", "Aktualisieren"),
Binding("t", "telegram_qr", "Telegram QR"),
Binding("d", "delete_conv", "Chat löschen"),
Binding("q", "quit_app", "Beenden"),
]
@@ -142,11 +144,11 @@ class MainScreen(Screen):
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}]"
)
text = msg.get("text", "").replace("[", "\\[")
if direction == "outbound":
log.write(f"[dim]{ts}[/dim] [dim]{direction_prefix}{text}{status_suffix}[/dim]")
else:
log.write(f"[dim]{ts}[/dim] {direction_prefix}{text}{status_suffix}")
# ── Background-Polling ─────────────────────────────────────────────────────
@@ -161,6 +163,20 @@ class MainScreen(Screen):
# ── Events ─────────────────────────────────────────────────────────────────
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
"""Cursor-Bewegung: conv_id und conv sofort merken (für d/t-Taste etc.)."""
conv_id = self._conv_id_map.get(event.cursor_row)
if conv_id:
self._current_conv_id = conv_id
# _current_conv asynchron nachladen
asyncio.create_task(self._fetch_current_conv(conv_id))
async def _fetch_current_conv(self, conv_id: str) -> None:
try:
self._current_conv = await self._api.get_conversation(conv_id)
except Exception:
pass
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)
@@ -240,5 +256,108 @@ class MainScreen(Screen):
if self._current_conv_id:
await self._load_messages(self._current_conv_id)
def action_delete_conv(self) -> None:
if not self._current_conv_id:
self.notify("Kein Chat ausgewählt.", severity="warning")
return
title = (self._current_conv or {}).get("title") or self._current_conv_id[:8]
self.app.push_screen(
_ConfirmScreen(f"Chat löschen?\n\n{title}\n\nAlle Nachrichten werden entfernt."),
self._delete_conv_callback,
)
def _delete_conv_callback(self, confirmed: bool) -> None:
if confirmed:
asyncio.create_task(self._do_delete_conv())
async def _do_delete_conv(self) -> None:
conv_id = self._current_conv_id
if not conv_id:
return
try:
await self._api.delete_conversation(conv_id)
self._current_conv_id = None
self._current_conv = None
self.query_one("#chat-header", Static).update("Kein Chat geöffnet")
self.query_one("#message-log", RichLog).clear()
await self._load_conversations()
self.notify("Chat gelöscht.")
except Exception as exc:
self.notify(f"Löschen fehlgeschlagen: {exc}", severity="error")
def action_telegram_qr(self) -> None:
asyncio.create_task(self._generate_telegram_qr())
async def _generate_telegram_qr(self) -> None:
if not self._current_conv_id:
self.notify("Bitte erst eine Konversation auswählen.", severity="warning")
return
conv = self._current_conv or {}
contact_id = conv.get("contact_id")
if not contact_id:
try:
details = await self._api.get_conversation(self._current_conv_id)
contact_id = (details or {}).get("contact_id")
except Exception:
pass
if not contact_id:
self.notify("Kein Kontakt zur Konversation gefunden.", severity="error")
return
try:
import base64
import tempfile
import webbrowser
from config import settings
png_bytes = await self._api.get_telegram_qr(contact_id)
bot = settings.telegram_bot_username.lstrip("@")
invite_url = f"https://t.me/{bot}?start={contact_id}"
b64 = base64.b64encode(png_bytes).decode()
contact_name = conv.get("title") or contact_id
html = f"""<!DOCTYPE html>
<html lang="de">
<head><meta charset="utf-8"><title>Telegram QR {contact_name}</title>
<style>
body {{font-family:sans-serif;text-align:center;padding:2rem;background:#1a1a2e;color:#eee}}
img {{width:300px;height:300px;border:8px solid #fff;border-radius:12px;margin:1rem}}
a {{color:#64b5f6;word-break:break-all}}
h2 {{margin-bottom:0}}
p {{margin:.5rem 0}}
</style></head>
<body>
<h2>Telegram Invite</h2>
<p>{contact_name}</p>
<img src="data:image/png;base64,{b64}" alt="QR-Code">
<p><a href="{invite_url}">{invite_url}</a></p>
<p style="font-size:.85rem;color:#aaa">Kunde scannt QR-Code → Telegram öffnet Bot → Verbindung hergestellt</p>
</body></html>"""
with tempfile.NamedTemporaryFile(
delete=False, suffix=".html", mode="w", encoding="utf-8"
) as tmp:
tmp.write(html)
tmp_path = tmp.name
webbrowser.open(f"file://{tmp_path}")
self.notify(f"QR-Code im Browser geöffnet | {invite_url}", timeout=8)
except Exception as exc:
self.notify(f"QR-Code Fehler: {exc}", severity="error")
def action_quit_app(self) -> None:
self.app.exit()
class _ConfirmScreen(ModalScreen[bool]):
"""Einfacher Ja/Nein-Dialog."""
def __init__(self, message: str) -> None:
super().__init__()
self._message = message
def compose(self) -> ComposeResult:
from textual.widgets import Label
with Vertical(id="compose-dialog"):
yield Label(self._message, id="confirm-msg")
with Horizontal(id="compose-buttons"):
yield Button("Abbrechen", variant="default", id="btn-no")
yield Button("Löschen", variant="error", id="btn-yes")
def on_button_pressed(self, event: Button.Pressed) -> None:
self.dismiss(event.button.id == "btn-yes")

View File

@@ -14,9 +14,9 @@ Screen {
/* ── Seitenleiste (Konversationsliste) ────────────────────────────────── */
#sidebar {
width: 28;
min-width: 22;
max-width: 40;
width: 40;
min-width: 30;
max-width: 55;
border-right: solid $primary-darken-1;
}

View File

@@ -1,21 +1,20 @@
"""MCM TUI Standalone Entry Point für textual serve.
"""MCM TUI Standalone Entry Point.
Startet nur die TUI (kein API-Server, keine Channels).
Die TUI spricht via HTTP gegen den laufenden MCM-API-Server.
Wird von serve_tui.py per textual-serve als Subprocess pro Browser-Session gestartet.
Kann auch direkt im Terminal gestartet werden.
Verwendung:
# API-Server muss bereits laufen:
python main_api_only.py
# Direkt im Terminal:
python tui_standalone.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
# Als Browser-App via serve_tui.py:
python serve_tui.py
"""
from tui.app import MCMApp
app = MCMApp()
# Für textual serve (ältere Variante) Klasse als Factory
app = MCMApp
if __name__ == "__main__":
app.run()
MCMApp().run()