Compare commits
19 Commits
4c5f6a16a7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0dc718f541 | |||
| e9f3d28554 | |||
| 28d3b36b78 | |||
| 7c237836e8 | |||
| 2b15a99dc4 | |||
| 5f9d6b327b | |||
| b880cabcde | |||
| 2324ffd714 | |||
| 7a008470ef | |||
| b0c6ba44de | |||
| 0f73341c8b | |||
| 18ad0735ef | |||
| 73619fbc9c | |||
| d7887ae0c9 | |||
| 23fb37cb1a | |||
| 3177146267 | |||
| 9046708015 | |||
| d67479309d | |||
| 4c45c8b17b |
28
.claude/commands/todo.md
Normal file
28
.claude/commands/todo.md
Normal 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.
|
||||||
@@ -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
3
.gitignore
vendored
@@ -36,3 +36,6 @@ logs/
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Todo-Liste (lokal, nicht im Repo)
|
||||||
|
TODO.md
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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"'},
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
timeout = aiohttp.ClientTimeout(total=15)
|
||||||
|
while True:
|
||||||
try:
|
try:
|
||||||
async with self._get_session().get(url, params={"receiveTimeout": 5}) as resp:
|
async with self._get_session().get(
|
||||||
if resp.status != 200:
|
url, params={"receiveTimeout": 5}, timeout=timeout
|
||||||
return
|
) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
if not data:
|
if data:
|
||||||
return
|
|
||||||
receipt_id = data.get("receiptId")
|
receipt_id = data.get("receiptId")
|
||||||
body = data.get("body", {})
|
body = data.get("body", {})
|
||||||
await self._process_notification(body)
|
await self._process_notification(body)
|
||||||
if receipt_id:
|
if receipt_id:
|
||||||
await self._delete_notification(receipt_id)
|
await self._delete_notification(receipt_id)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("WhatsApp poll error: %s", 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()
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
18
main.py
18
main.py
@@ -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)")
|
||||||
|
try:
|
||||||
await api_task
|
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__":
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
73
serve_tui.py
Normal 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()
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ 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)
|
||||||
|
# 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
|
channel_conv_id = recipient_id
|
||||||
|
|
||||||
conv = conversation_service.get_or_create(
|
conv = conversation_service.get_or_create(
|
||||||
@@ -109,6 +113,24 @@ async def handle_inbound(payload: dict[str, Any]) -> None:
|
|||||||
channel = payload["channel"]
|
channel = payload["channel"]
|
||||||
|
|
||||||
if channel == "telegram":
|
if channel == "telegram":
|
||||||
|
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(
|
contact = contact_service.get_or_create_by_telegram(
|
||||||
db,
|
db,
|
||||||
payload["sender_telegram_id"],
|
payload["sender_telegram_id"],
|
||||||
|
|||||||
63
start.sh
Executable file
63
start.sh
Executable 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user