diff --git a/.env.example b/.env.example index 1b20dec..2fe0c3c 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,8 @@ DEBUG=false # ── Telegram ─────────────────────────────────────────────── TELEGRAM_TOKEN= +# Bot-Username ohne @ (für QR-Code / Invite-Links) +TELEGRAM_BOT_USERNAME=mcm_bot # ── WhatsApp (Green API) ─────────────────────────────────── # Konto anlegen unter: https://console.green-api.com diff --git a/api/routes/contacts.py b/api/routes/contacts.py index 2e32f1f..1378a75 100644 --- a/api/routes/contacts.py +++ b/api/routes/contacts.py @@ -1,7 +1,12 @@ +import io + +import qrcode from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session from api.auth import require_api_key +from config import settings from db.database import get_db from schemas import ContactCreate, ContactResponse, ContactUpdate from services import contact_service @@ -58,3 +63,28 @@ def delete_contact( if not contact: raise HTTPException(status_code=404, detail="Contact not found") contact_service.delete(db, contact) + + +@router.get("/{contact_id}/telegram-qr", tags=["contacts"]) +def telegram_qr( + contact_id: str, + db: Session = Depends(get_db), + _: str = Depends(require_api_key), +): + """QR-Code als PNG: Invite-Link für Telegram-Bot mit Kontakt-ID als Deep-Link-Parameter.""" + contact = contact_service.get_by_id(db, contact_id) + if not contact: + raise HTTPException(status_code=404, detail="Contact not found") + + bot_username = settings.telegram_bot_username.lstrip("@") + invite_url = f"https://t.me/{bot_username}?start={contact_id}" + + img = qrcode.make(invite_url) + buf = io.BytesIO() + img.save(buf, format="PNG") + buf.seek(0) + return StreamingResponse( + buf, + media_type="image/png", + headers={"Content-Disposition": f'attachment; filename="telegram_qr_{contact_id}.png"'}, + ) diff --git a/channels/telegram_channel.py b/channels/telegram_channel.py index 8c231fa..a92bbe5 100644 --- a/channels/telegram_channel.py +++ b/channels/telegram_channel.py @@ -5,7 +5,7 @@ import logging from typing import TYPE_CHECKING, Any, Callable, Awaitable from telegram import Update -from telegram.ext import Application, ApplicationBuilder, MessageHandler, filters +from telegram.ext import Application, ApplicationBuilder, CommandHandler, MessageHandler, filters from channels.base import BaseChannel from config import settings @@ -62,6 +62,7 @@ class TelegramChannel(BaseChannel): return 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)) await self._app.initialize() @@ -97,6 +98,31 @@ class TelegramChannel(BaseChannel): except Exception as exc: logger.error("Telegram polling error: %s", exc) + async def _handle_start(self, update: Update, context: Any) -> None: + """Verarbeitet /start [contact_id] — verknüpft Kontakt mit chat_id.""" + if not update.message or not self._inbound_callback: + return + msg = update.message + args = context.args # Liste der Parameter nach /start + contact_id = args[0] if args else None + + payload: dict[str, Any] = { + "channel": "telegram", + "channel_message_id": str(msg.message_id), + "sender_telegram_id": str(msg.from_user.id) if msg.from_user else None, + "sender_name": ( + (msg.from_user.full_name or msg.from_user.username) + if msg.from_user + else "Unknown" + ), + "chat_id": str(msg.chat.id), + "text": "/start", + "reply_to_id": None, + "link_contact_id": contact_id, # Kontakt aus QR-Code verknüpfen + } + await self._inbound_callback(payload) + await msg.reply_text("Willkommen! Sie sind jetzt mit MCM verbunden.") + async def _handle_message(self, update: Update, context: Any) -> None: if not update.message or not self._inbound_callback: return diff --git a/channels/whatsapp_channel.py b/channels/whatsapp_channel.py index b9dd016..aa582f6 100644 --- a/channels/whatsapp_channel.py +++ b/channels/whatsapp_channel.py @@ -85,7 +85,8 @@ class WhatsAppChannel(BaseChannel): return url = f"{self._base_url}/receiveNotification/{self._token}" 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: return data = await resp.json() diff --git a/config.py b/config.py index ef31d24..0fe9d17 100644 --- a/config.py +++ b/config.py @@ -13,6 +13,7 @@ class Settings(BaseSettings): # Telegram telegram_token: str = "" + telegram_bot_username: str = "mcm_bot" # WhatsApp (Green API) whatsapp_id_instance: str = "" diff --git a/requirements.txt b/requirements.txt index ddc18fa..3afd3f9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,3 +15,4 @@ httpx>=0.27.0 python-jose[cryptography]>=3.3.0 bcrypt>=4.0.0 textual-serve>=1.1.0 +qrcode[pil]>=7.4.2 diff --git a/services/message_service.py b/services/message_service.py index 9fe0085..889835b 100644 --- a/services/message_service.py +++ b/services/message_service.py @@ -109,11 +109,29 @@ async def handle_inbound(payload: dict[str, Any]) -> None: channel = payload["channel"] if channel == "telegram": - contact = contact_service.get_or_create_by_telegram( - db, - payload["sender_telegram_id"], - name=payload.get("sender_name", "Unknown"), - ) + link_contact_id = payload.get("link_contact_id") + if link_contact_id: + # Kontakt aus QR-Code: telegram_id verknüpfen + existing = contact_service.get_by_id(db, link_contact_id) + if existing: + from schemas import ContactUpdate + contact_service.update( + db, existing, + ContactUpdate(telegram_id=payload["sender_telegram_id"]) + ) + contact = existing + else: + contact = contact_service.get_or_create_by_telegram( + db, + payload["sender_telegram_id"], + name=payload.get("sender_name", "Unknown"), + ) + else: + contact = contact_service.get_or_create_by_telegram( + db, + payload["sender_telegram_id"], + name=payload.get("sender_name", "Unknown"), + ) channel_conv_id = payload["chat_id"] else: contact = contact_service.get_or_create_by_phone( diff --git a/tui/api_client.py b/tui/api_client.py index d547a31..a057b59 100644 --- a/tui/api_client.py +++ b/tui/api_client.py @@ -101,6 +101,16 @@ class MCMApiClient: async def get_contacts(self) -> list[dict[str, Any]]: 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 ───────────────────────────────────────────────────────────────── async def get_channel_status(self) -> dict[str, Any]: diff --git a/tui/screens/main_screen.py b/tui/screens/main_screen.py index c303583..7d503c4 100644 --- a/tui/screens/main_screen.py +++ b/tui/screens/main_screen.py @@ -24,6 +24,7 @@ class MainScreen(Screen): BINDINGS = [ Binding("n", "new_message", "Neu"), Binding("r", "refresh", "Aktualisieren"), + Binding("t", "telegram_qr", "Telegram QR"), Binding("q", "quit_app", "Beenden"), ] @@ -240,5 +241,63 @@ class MainScreen(Screen): if 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""" + +Telegram QR – {contact_name} + + +

Telegram Invite

+

{contact_name}

+QR-Code +

{invite_url}

+

Kunde scannt QR-Code → Telegram öffnet Bot → Verbindung hergestellt

+""" + with tempfile.NamedTemporaryFile( + delete=False, suffix=".html", mode="w", encoding="utf-8" + ) as tmp: + tmp.write(html) + tmp_path = tmp.name + webbrowser.open(f"file://{tmp_path}") + self.notify(f"QR-Code im Browser geöffnet | {invite_url}", timeout=8) + except Exception as exc: + self.notify(f"QR-Code Fehler: {exc}", severity="error") + def action_quit_app(self) -> None: self.app.exit()