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:
@@ -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
|
||||||
|
|||||||
@@ -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"'},
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user