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
This commit is contained in:
2026-03-13 14:45:06 +01:00
parent 73619fbc9c
commit 18ad0735ef
9 changed files with 155 additions and 7 deletions

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

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

@@ -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
@@ -62,6 +62,7 @@ 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(CommandHandler("start", self._handle_start))
self._app.add_handler(MessageHandler(~filters.COMMAND, self._handle_message)) self._app.add_handler(MessageHandler(~filters.COMMAND, self._handle_message))
await self._app.initialize() await self._app.initialize()
@@ -97,6 +98,31 @@ class TelegramChannel(BaseChannel):
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

@@ -85,7 +85,8 @@ class WhatsAppChannel(BaseChannel):
return return
url = f"{self._base_url}/receiveNotification/{self._token}" url = f"{self._base_url}/receiveNotification/{self._token}"
try: try:
async with self._get_session().get(url, params={"receiveTimeout": 5}) as resp: timeout = aiohttp.ClientTimeout(total=8)
async with self._get_session().get(url, params={"receiveTimeout": 3}, timeout=timeout) as resp:
if resp.status != 200: if resp.status != 200:
return return
data = await resp.json() data = await resp.json()

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 = ""

View File

@@ -15,3 +15,4 @@ 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 textual-serve>=1.1.0
qrcode[pil]>=7.4.2

View File

@@ -109,11 +109,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(

View File

@@ -101,6 +101,16 @@ 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 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

@@ -24,6 +24,7 @@ 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("q", "quit_app", "Beenden"), Binding("q", "quit_app", "Beenden"),
] ]
@@ -240,5 +241,63 @@ 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_telegram_qr(self) -> None:
asyncio.create_task(self._generate_telegram_qr())
async def _generate_telegram_qr(self) -> None:
if not self._current_conv:
self.notify("Bitte erst eine Konversation auswählen.", severity="warning")
return
conv = self._current_conv
if conv.get("channel") != "telegram":
self.notify("QR-Code nur für Telegram-Konversationen.", severity="warning")
return
contact_id = conv.get("contact_id")
if not contact_id:
try:
details = await self._api.get_conversation(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()