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 ───────────────────────────────────────────────
TELEGRAM_TOKEN= TELEGRAM_TOKEN=
# Bot-Username ohne @ (für QR-Code / Invite-Links)
TELEGRAM_BOT_USERNAME=mcm_bot
# ── WhatsApp (Green API) ─────────────────────────────────── # ── WhatsApp (Green API) ───────────────────────────────────
# Konto anlegen unter: https://console.green-api.com # Konto anlegen unter: https://console.green-api.com
@@ -24,7 +26,7 @@ SMS_ENABLED=false
DATABASE_URL=sqlite:///./mcm.db DATABASE_URL=sqlite:///./mcm.db
# ── TUI Web-Modus (textual serve) ────────────────────────── # ── 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 WEB_PORT=8001
# ── Benutzer-Authentifizierung ────────────────────────────────────────────── # ── Benutzer-Authentifizierung ──────────────────────────────────────────────

3
.gitignore vendored
View File

@@ -36,3 +36,6 @@ logs/
# OS # OS
.DS_Store .DS_Store
Thumbs.db 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 .venv/bin/python main_api_only.py
# TUI als Browser-App (Terminal 2) # 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` 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 import APIRouter, Depends, HTTPException, status
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from api.auth import require_api_key from api.auth import require_api_key
from config import settings
from db.database import get_db from db.database import get_db
from schemas import ContactCreate, ContactResponse, ContactUpdate from schemas import ContactCreate, ContactResponse, ContactUpdate
from services import contact_service from services import contact_service
@@ -58,3 +63,28 @@ def delete_contact(
if not contact: if not contact:
raise HTTPException(status_code=404, detail="Contact not found") raise HTTPException(status_code=404, detail="Contact not found")
contact_service.delete(db, contact) 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 sqlalchemy.orm import Session
from api.auth import require_api_key from api.auth import require_api_key
@@ -20,14 +20,13 @@ def list_conversations(
result = [] result = []
for conv in convs: for conv in convs:
last_msg = conv.messages[-1] if conv.messages else None last_msg = conv.messages[-1] if conv.messages else None
contact_id = conv.participants[0].id if conv.participants else None
result.append( result.append(
ConversationResponse( 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, last_message=MessageResponse.model_validate(last_msg) if last_msg else None,
unread_count=conversation_service.unread_count(db, conv.id), unread_count=conversation_service.unread_count(db, conv.id),
contact_id=contact_id,
) )
) )
return result return result
@@ -43,10 +42,12 @@ def get_conversation(
if not conv: if not conv:
raise HTTPException(status_code=404, detail="Conversation not found") raise HTTPException(status_code=404, detail="Conversation not found")
last_msg = conv.messages[-1] if conv.messages else None last_msg = conv.messages[-1] if conv.messages else None
contact_id = conv.participants[0].id if conv.participants else None
return ConversationResponse( return 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, last_message=MessageResponse.model_validate(last_msg) if last_msg else None,
unread_count=conversation_service.unread_count(db, conv.id), 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) msgs = conversation_service.get_messages(db, conv_id, limit=limit, offset=offset)
conversation_service.mark_all_read(db, conv_id) conversation_service.mark_all_read(db, conv_id)
return [MessageResponse.model_validate(m) for m in msgs] 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 typing import TYPE_CHECKING, Any, Callable, Awaitable
from telegram import Update 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 channels.base import BaseChannel
from config import settings from config import settings
@@ -21,6 +21,7 @@ class TelegramChannel(BaseChannel):
def __init__(self) -> None: def __init__(self) -> None:
self._app: Application | None = None self._app: Application | None = None
self._polling_task: asyncio.Task | None = None
self._inbound_callback: Callable[[dict[str, Any]], Awaitable[None]] | 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: def set_inbound_callback(self, cb: Callable[[dict[str, Any]], Awaitable[None]]) -> None:
@@ -62,17 +63,31 @@ class TelegramChannel(BaseChannel):
return return
self._app = ApplicationBuilder().token(settings.telegram_token).build() self._app = ApplicationBuilder().token(settings.telegram_token).build()
self._app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self._handle_message)) self._app.add_handler(CommandHandler("start", self._handle_start))
self._app.add_handler(MessageHandler(filters.PHOTO | filters.DOCUMENT | filters.VOICE, self._handle_message)) self._app.add_handler(MessageHandler(~filters.COMMAND, self._handle_message))
await self._app.initialize() await self._app.initialize()
await self._app.start() await self._app.start()
# Long-Polling als Background-Task # Long-Polling als Background-Task (Referenz speichern für sauberes Cancel)
asyncio.create_task(self._polling_loop(), name="telegram-polling") self._polling_task = asyncio.create_task(self._polling_loop(), name="telegram-polling")
logger.info("Telegram channel started (long-polling)") logger.info("Telegram channel started (long-polling)")
async def stop(self) -> None: 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: if self._app:
try:
if self._app.updater.running:
await self._app.updater.stop()
except Exception:
pass
await self._app.stop() await self._app.stop()
await self._app.shutdown() await self._app.shutdown()
logger.info("Telegram channel stopped") logger.info("Telegram channel stopped")
@@ -83,13 +98,39 @@ class TelegramChannel(BaseChannel):
"""Endlos-Polling im Hintergrund.""" """Endlos-Polling im Hintergrund."""
try: try:
await self._app.updater.start_polling(allowed_updates=["message"]) await self._app.updater.start_polling(allowed_updates=["message"])
# Warte bis gestoppt # In PTB v20+ läuft der Updater als eigener asyncio-Task weiter
await self._app.updater.idle() # wir warten hier einfach, bis der Task abgebrochen wird.
await asyncio.Event().wait()
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
except Exception as exc: except Exception as exc:
logger.error("Telegram polling error: %s", 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: async def _handle_message(self, update: Update, context: Any) -> None:
if not update.message or not self._inbound_callback: if not update.message or not self._inbound_callback:
return return

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
from typing import Any, Callable, Awaitable from typing import Any, Callable, Awaitable
@@ -20,6 +21,7 @@ class WhatsAppChannel(BaseChannel):
) )
self._token = settings.whatsapp_api_token self._token = settings.whatsapp_api_token
self._session: aiohttp.ClientSession | None = None self._session: aiohttp.ClientSession | None = None
self._polling_task: asyncio.Task | None = None
self._inbound_callback: Callable[[dict[str, Any]], Awaitable[None]] | 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: 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"} return {"success": False, "channel_message_id": None, "error": "WhatsApp not configured"}
# chatId: Telefonnummer ohne + gefolgt von @c.us, z.B. "4917612345678@c.us" # 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}" url = f"{self._base_url}/sendMessage/{self._token}"
body: dict[str, Any] = {"chatId": chat_id, "message": text} body: dict[str, Any] = {"chatId": chat_id, "message": text}
if reply_to_id: if reply_to_id:
@@ -70,34 +72,46 @@ class WhatsAppChannel(BaseChannel):
logger.info("WhatsApp disabled (no credentials configured)") logger.info("WhatsApp disabled (no credentials configured)")
return return
self._session = aiohttp.ClientSession() 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") logger.info("WhatsApp channel started")
async def stop(self) -> None: 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: if self._session and not self._session.closed:
await self._session.close() await self._session.close()
logger.info("WhatsApp channel stopped") logger.info("WhatsApp channel stopped")
# ── Polling (wird vom Scheduler aufgerufen) ──────────────────────────────── # ── Polling-Schleife ───────────────────────────────────────────────────────
async def poll_incoming(self) -> None: async def _polling_loop(self) -> None:
"""Eingehende Nachrichten per Polling abrufen (Green API Notification Queue).""" """Endlos-Polling: direkt nach Antwort wieder starten, kein Overlap möglich."""
if not settings.whatsapp_enabled:
return
url = f"{self._base_url}/receiveNotification/{self._token}" url = f"{self._base_url}/receiveNotification/{self._token}"
try: timeout = aiohttp.ClientTimeout(total=15)
async with self._get_session().get(url, params={"receiveTimeout": 5}) as resp: while True:
if resp.status != 200: try:
return async with self._get_session().get(
data = await resp.json() url, params={"receiveTimeout": 5}, timeout=timeout
if not data: ) as resp:
return if resp.status == 200:
receipt_id = data.get("receiptId") data = await resp.json()
body = data.get("body", {}) if data:
await self._process_notification(body) receipt_id = data.get("receiptId")
if receipt_id: body = data.get("body", {})
await self._delete_notification(receipt_id) await self._process_notification(body)
except Exception as exc: if receipt_id:
logger.error("WhatsApp poll error: %s", exc) 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: async def _delete_notification(self, receipt_id: int) -> None:
url = f"{self._base_url}/deleteNotification/{self._token}/{receipt_id}" url = f"{self._base_url}/deleteNotification/{self._token}/{receipt_id}"
@@ -127,6 +141,24 @@ class WhatsAppChannel(BaseChannel):
} }
await self._inbound_callback(payload) 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: def _get_session(self) -> aiohttp.ClientSession:
if not self._session or self._session.closed: if not self._session or self._session.closed:
self._session = aiohttp.ClientSession() self._session = aiohttp.ClientSession()

View File

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

20
main.py
View File

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

View File

@@ -14,3 +14,5 @@ python-dotenv>=1.0.0
httpx>=0.27.0 httpx>=0.27.0
python-jose[cryptography]>=3.3.0 python-jose[cryptography]>=3.3.0
bcrypt>=4.0.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 created_at: datetime
last_message: Optional[MessageResponse] = None last_message: Optional[MessageResponse] = None
unread_count: int = 0 unread_count: int = 0
contact_id: Optional[str] = None
# ── Channel Status ───────────────────────────────────────────────────────────── # ── 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: def get_by_phone(db: Session, phone: str) -> Contact | None:
return ( # Normalisierung: mit und ohne führendes + suchen
db.query(Contact) variants = {phone, "+" + phone.lstrip("+"), phone.lstrip("+")}
.filter((Contact.phone == phone) | (Contact.whatsapp_phone == phone)) from sqlalchemy import or_
.first() 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: 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: def mark_all_read(db: Session, conv_id: str) -> None:
now = datetime.utcnow() now = datetime.utcnow()
db.query(Message).filter( db.query(Message).filter(

View File

@@ -51,7 +51,11 @@ async def send(db: Session, req: SendMessageRequest) -> Message:
else: else:
recipient_id = req.recipient_phone recipient_id = req.recipient_phone
contact = contact_service.get_or_create_by_phone(db, recipient_id) 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( conv = conversation_service.get_or_create(
db, req.channel, channel_conv_id, contact db, req.channel, channel_conv_id, contact
@@ -109,11 +113,29 @@ async def handle_inbound(payload: dict[str, Any]) -> None:
channel = payload["channel"] channel = payload["channel"]
if channel == "telegram": if channel == "telegram":
contact = contact_service.get_or_create_by_telegram( link_contact_id = payload.get("link_contact_id")
db, if link_contact_id:
payload["sender_telegram_id"], # Kontakt aus QR-Code: telegram_id verknüpfen
name=payload.get("sender_name", "Unknown"), 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"] channel_conv_id = payload["chat_id"]
else: else:
contact = contact_service.get_or_create_by_phone( 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") _scheduler = AsyncIOScheduler(timezone="UTC")
# WhatsApp-Polling alle 5 Sekunden # WhatsApp-Polling alle 5 Sekunden (Green API erlaubt 100 req/s)
_scheduler.add_job( _scheduler.add_job(
_poll_whatsapp, _poll_whatsapp,
trigger=IntervalTrigger(seconds=5), trigger=IntervalTrigger(seconds=5),
@@ -29,6 +29,7 @@ def build_scheduler(whatsapp: "WhatsAppChannel") -> AsyncIOScheduler:
name="WhatsApp incoming messages", name="WhatsApp incoming messages",
max_instances=1, max_instances=1,
coalesce=True, coalesce=True,
misfire_grace_time=5,
) )
return _scheduler return _scheduler

View File

@@ -101,6 +101,24 @@ class MCMApiClient:
async def get_contacts(self) -> list[dict[str, Any]]: async def get_contacts(self) -> list[dict[str, Any]]:
return await self._get("/contacts") 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 ───────────────────────────────────────────────────────────────── # ── Status ─────────────────────────────────────────────────────────────────
async def get_channel_status(self) -> dict[str, Any]: 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.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 ModalScreen, 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 textual import work
@@ -24,6 +24,8 @@ class MainScreen(Screen):
BINDINGS = [ BINDINGS = [
Binding("n", "new_message", "Neu"), Binding("n", "new_message", "Neu"),
Binding("r", "refresh", "Aktualisieren"), Binding("r", "refresh", "Aktualisieren"),
Binding("t", "telegram_qr", "Telegram QR"),
Binding("d", "delete_conv", "Chat löschen"),
Binding("q", "quit_app", "Beenden"), Binding("q", "quit_app", "Beenden"),
] ]
@@ -142,11 +144,11 @@ class MainScreen(Screen):
direction = msg.get("direction", "outbound") direction = msg.get("direction", "outbound")
direction_prefix = "" if direction == "outbound" else "" direction_prefix = "" if direction == "outbound" else ""
status_suffix = "" if msg.get("status") == "failed" else "" status_suffix = "" if msg.get("status") == "failed" else ""
style = "dim" if direction == "outbound" else "" text = msg.get("text", "").replace("[", "\\[")
text = msg.get("text", "") if direction == "outbound":
log.write( log.write(f"[dim]{ts}[/dim] [dim]{direction_prefix}{text}{status_suffix}[/dim]")
f"[dim]{ts}[/dim] [{style}]{direction_prefix}{text}{status_suffix}[/{style}]" else:
) log.write(f"[dim]{ts}[/dim] {direction_prefix}{text}{status_suffix}")
# ── Background-Polling ───────────────────────────────────────────────────── # ── Background-Polling ─────────────────────────────────────────────────────
@@ -161,6 +163,20 @@ class MainScreen(Screen):
# ── Events ───────────────────────────────────────────────────────────────── # ── 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: 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)
@@ -240,5 +256,108 @@ class MainScreen(Screen):
if self._current_conv_id: if self._current_conv_id:
await self._load_messages(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: def action_quit_app(self) -> None:
self.app.exit() 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) ────────────────────────────────── */ /* ── Seitenleiste (Konversationsliste) ────────────────────────────────── */
#sidebar { #sidebar {
width: 28; width: 40;
min-width: 22; min-width: 30;
max-width: 40; max-width: 55;
border-right: solid $primary-darken-1; 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). Wird von serve_tui.py per textual-serve als Subprocess pro Browser-Session gestartet.
Die TUI spricht via HTTP gegen den laufenden MCM-API-Server. Kann auch direkt im Terminal gestartet werden.
Verwendung: Verwendung:
# API-Server muss bereits laufen: # Direkt im Terminal:
python main_api_only.py python tui_standalone.py
# TUI im Browser starten: # Als Browser-App via serve_tui.py:
.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://<host>:8001
""" """
from tui.app import MCMApp from tui.app import MCMApp
app = MCMApp() # Für textual serve (ältere Variante) Klasse als Factory
app = MCMApp
if __name__ == "__main__": if __name__ == "__main__":
app.run() MCMApp().run()