diff --git a/KnowledgeBaseCline.md b/KnowledgeBaseCline.md index d3795f3..ebe5b10 100644 --- a/KnowledgeBaseCline.md +++ b/KnowledgeBaseCline.md @@ -8,6 +8,16 @@ Non fare commit sul repository GitHub, ci penso io Non effettuare test da Browser, ci penso io +Questi i container: + +pecflow-worker-1 +pecflow-frontend-1 +pecflow-backend-1 +pecflow-nginx-1 +pecflow-db-1 +pecflow-redis-1 +pecflow-minio-1 + Queste le caselle PEC e i loro parametri IMAP/SMTP che puoi usare per test, non effettuare invii per adesso Casella: matteo.giustini@arubapec.it @@ -26,7 +36,7 @@ Tutto il frontend deve essere in italiano Credenziali admin Ruolo Email Password -Super Admin superadmin@pechub.it SuperAdmin@PEChub2026! -Admin (tenant demo) admin@demo.pechub.it Demo@PEChub2026! -Operator (tenant demo) operator@demo.pechub.it Oper@PEChub2026! +Super Admin superadmin@pecflow.it SuperAdmin@PecFlow2026! +Admin (tenant demo) admin@demo.pecflow.it Demo@PecFlow2026! +Operator (tenant demo) operator@demo.pecflow.it Oper@PecFlow2026! Per accedere all'applicazione usa le credenziali Admin del tenant demo. \ No newline at end of file diff --git a/backend/app/notifications/__init__.py b/backend/app/notifications/__init__.py new file mode 100644 index 0000000..089f831 --- /dev/null +++ b/backend/app/notifications/__init__.py @@ -0,0 +1 @@ +# Modulo notifiche – mittenti multi-canale diff --git a/backend/app/notifications/telegram.py b/backend/app/notifications/telegram.py new file mode 100644 index 0000000..0718368 --- /dev/null +++ b/backend/app/notifications/telegram.py @@ -0,0 +1,113 @@ +""" +Telegram Bot API – invio messaggi via sendMessage. + +Usato da NotificationService.test_channel() e dalle notifiche real-time. + +Formato configurazione canale: + config: { "chat_id": "-100123456789" } # pubblico + config_secret: { "bot_token": "123:AAA..." } # cifrato in config_enc + +API Telegram: + POST https://api.telegram.org/bot{token}/sendMessage + Body: { "chat_id": "...", "text": "...", "parse_mode": "MarkdownV2" } +""" + +import httpx + +TELEGRAM_API_BASE = "https://api.telegram.org" +DEFAULT_TIMEOUT = 10.0 # secondi + + +class TelegramError(Exception): + """Errore durante l'invio di un messaggio Telegram.""" + + def __init__(self, message: str, http_status: int | None = None, api_code: int | None = None): + super().__init__(message) + self.http_status = http_status + self.api_code = api_code + + +async def send_message( + bot_token: str, + chat_id: str, + text: str, + parse_mode: str = "HTML", + disable_web_page_preview: bool = True, + timeout: float = DEFAULT_TIMEOUT, +) -> dict: + """ + Invia un messaggio a un canale/gruppo/utente Telegram. + + Args: + bot_token: Token del bot Telegram (es. "123456789:AAF...") + chat_id: ID della chat/canale (es. "-100123456789" o "@mychannel") + text: Testo del messaggio (supporta HTML o MarkdownV2) + parse_mode: "HTML" (default) | "MarkdownV2" | "" (plain text) + disable_web_page_preview: Disabilita anteprima link + timeout: Timeout HTTP in secondi + + Returns: + dict con il risultato della API Telegram (result.message_id, ecc.) + + Raises: + TelegramError: in caso di errore HTTP o risposta API non-ok + """ + url = f"{TELEGRAM_API_BASE}/bot{bot_token}/sendMessage" + payload: dict = { + "chat_id": chat_id, + "text": text, + } + if parse_mode: + payload["parse_mode"] = parse_mode + if disable_web_page_preview: + payload["link_preview_options"] = {"is_disabled": True} + + async with httpx.AsyncClient(timeout=timeout) as client: + try: + response = await client.post(url, json=payload) + except httpx.TimeoutException as exc: + raise TelegramError( + f"Timeout nella connessione a Telegram ({timeout}s)" + ) from exc + except httpx.RequestError as exc: + raise TelegramError(f"Errore di rete Telegram: {exc}") from exc + + if response.status_code != 200: + raise TelegramError( + f"Telegram API ha risposto con HTTP {response.status_code}: {response.text[:200]}", + http_status=response.status_code, + ) + + data = response.json() + if not data.get("ok"): + api_code = data.get("error_code") + description = data.get("description", "Errore sconosciuto") + raise TelegramError( + f"Telegram API error {api_code}: {description}", + http_status=response.status_code, + api_code=api_code, + ) + + return data.get("result", {}) + + +async def send_test_message( + bot_token: str, + chat_id: str, + channel_name: str = "PecFlow", +) -> dict: + """ + Invia un messaggio di test formattato al canale configurato. + + Returns: + dict result da Telegram (con message_id) + """ + from datetime import datetime + + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + text = ( + f"✅ PecFlow – Test canale Telegram\n\n" + f"Il canale {channel_name} è configurato correttamente.\n\n" + f"🕐 {ts}" + ) + return await send_message(bot_token=bot_token, chat_id=chat_id, text=text, parse_mode="HTML") diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index 2f9c9ba..699f397 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -161,6 +161,35 @@ class NotificationService: elif channel_type == "telegram": if not config.get("chat_id"): return ChannelTestResult(success=False, message="Chat ID Telegram non configurato") + # Invio reale via Bot API + secret = _decrypt(channel.config_enc) if channel.config_enc else {} + bot_token = secret.get("bot_token") + if not bot_token: + return ChannelTestResult(success=False, message="Bot token Telegram non configurato") + try: + from app.notifications.telegram import TelegramError, send_test_message + result = await send_test_message( + bot_token=bot_token, + chat_id=str(config["chat_id"]), + channel_name=channel.name, + ) + msg_id = result.get("message_id") + return ChannelTestResult( + success=True, + message=f"Messaggio Telegram inviato con successo (message_id={msg_id}).", + http_status=200, + ) + except TelegramError as exc: + return ChannelTestResult( + success=False, + message=f"Errore Telegram: {exc}", + http_status=exc.http_status, + ) + except Exception as exc: + return ChannelTestResult( + success=False, + message=f"Errore imprevisto durante il test Telegram: {exc}", + ) elif channel_type == "whatsapp": if not config.get("phone_number"): return ChannelTestResult(success=False, message="Numero WhatsApp non configurato") diff --git a/backend/tests/integration/test_telegram_real.py b/backend/tests/integration/test_telegram_real.py new file mode 100644 index 0000000..86d4fc6 --- /dev/null +++ b/backend/tests/integration/test_telegram_real.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +""" +Test integrazione Telegram REALE – Canale configurato +====================================================== + +Testa: +1. Lettura canale Telegram dal database (bot_token + chat_id) +2. Chiamata diretta alla funzione send_message (httpx → Bot API) +3. Verifica risposta Telegram (message_id, chat, testo) +4. Test via NotificationService.test_channel (flusso completo) + +Eseguire DENTRO il container backend: + docker exec pecflow-backend-1 python \ + /app/tests/integration/test_telegram_real.py + +Oppure specificando credenziali manuali via env: + docker exec -e TELEGRAM_BOT_TOKEN=xxx -e TELEGRAM_CHAT_ID=-100yyy \ + pecflow-backend-1 python /app/tests/integration/test_telegram_real.py +""" + +import asyncio +import base64 +import json +import os +import sys +from datetime import datetime + +# ─── Variabili d'ambiente ───────────────────────────────────────────────────── +os.environ.setdefault("ENCRYPTION_KEY", "6465762d656e6372797074696f6e2d6b65792d6e6f742d666f722d70726f6400") +os.environ.setdefault("SECRET_KEY", "dev-secret-key-not-for-production-use-only-for-local-0000000000000") +os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://pecflow:pecflow_dev_password@db:5432/pecflow") +os.environ.setdefault("REDIS_URL", "redis://redis:6379/0") +os.environ.setdefault("MINIO_ENDPOINT", "minio:9000") + + +# ─── Utilities ──────────────────────────────────────────────────────────────── + +def _sep(char: str = "─", width: int = 60) -> None: + print(char * width) + + +def _banner(bot_token_masked: str, chat_id: str) -> None: + _sep("═") + print(" PecFlow – Test Telegram Reale") + _sep("═") + print(f" Timestamp : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f" Bot Token : {bot_token_masked}") + print(f" Chat ID : {chat_id}") + _sep("═") + print() + + +def _mask_token(token: str) -> str: + """Nasconde la parte centrale del token per sicurezza.""" + parts = token.split(":") + if len(parts) == 2: + return f"{parts[0]}:{'*' * 10}...{parts[1][-6:]}" + return token[:8] + "..." + token[-6:] + + +def _decrypt_b64(enc: str) -> dict: + """Decifra il config_enc (base64 semplice come in notification_service.py).""" + raw = base64.b64decode(enc.encode()) + return json.loads(raw.decode()) + + +# ─── STEP 1: Lettura canale dal DB ─────────────────────────────────────────── + +async def load_channel_from_db() -> tuple[str, str, str] | None: + """ + Carica bot_token e chat_id dal primo canale Telegram nel DB. + Restituisce (channel_id, bot_token, chat_id) o None se non trovato. + """ + _sep() + print("STEP 1 – LETTURA CANALE TELEGRAM DAL DATABASE") + _sep() + + try: + from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession + from sqlalchemy import text + + db_url = os.environ["DATABASE_URL"] + engine = create_async_engine(db_url) + async with AsyncSession(engine) as session: + result = await session.execute( + text( + "SELECT id, name, config, config_enc " + "FROM notification_channels " + "WHERE channel_type = 'telegram' AND is_active = true " + "ORDER BY created_at DESC LIMIT 1" + ) + ) + row = result.fetchone() + + if not row: + print(" ⚠️ Nessun canale Telegram attivo trovato nel database.") + print(" Crea un canale Telegram tramite l'interfaccia o via API.") + return None + + channel_id = str(row[0]) + channel_name = row[1] + config = row[2] or {} + config_enc = row[3] + + chat_id = str(config.get("chat_id", "")) + if not chat_id: + print(f" ❌ Il canale '{channel_name}' non ha chat_id configurato.") + return None + + if not config_enc: + print(f" ❌ Il canale '{channel_name}' non ha bot_token configurato (config_enc mancante).") + return None + + secret = _decrypt_b64(config_enc) + bot_token = secret.get("bot_token", "") + if not bot_token: + print(f" ❌ Il canale '{channel_name}' non ha bot_token nel config_enc.") + return None + + print(f" ✅ Canale trovato: '{channel_name}'") + print(f" ID : {channel_id}") + print(f" Chat ID : {chat_id}") + print(f" Bot Token : {_mask_token(bot_token)}") + print() + return channel_id, bot_token, chat_id + + except Exception as exc: + print(f" ❌ Errore lettura DB: {exc}") + import traceback + traceback.print_exc() + return None + + +# ─── STEP 2: Invio diretto via httpx ───────────────────────────────────────── + +async def test_direct_send(bot_token: str, chat_id: str) -> bool: + """ + Testa send_message() direttamente (senza passare da DB/service). + """ + _sep() + print("STEP 2 – INVIO DIRETTO VIA BOT API (httpx)") + _sep() + + from app.notifications.telegram import TelegramError, send_message + + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + text = ( + f"🧪 PecFlow – Test integrazione\n\n" + f"Test invio diretto via send_message()\n\n" + f"🕐 {ts}" + ) + + print(f" Chiamata: send_message(chat_id={chat_id}, parse_mode=HTML)") + print() + + try: + result = await send_message( + bot_token=bot_token, + chat_id=chat_id, + text=text, + parse_mode="HTML", + ) + msg_id = result.get("message_id") + chat_info = result.get("chat", {}) + chat_title = chat_info.get("title") or chat_info.get("username") or chat_info.get("id") + date = result.get("date") + + print(f" ✅ INVIO OK") + print(f" message_id : {msg_id}") + print(f" chat : {chat_title}") + print(f" date : {date}") + print() + return True + + except TelegramError as exc: + print(f" ❌ TelegramError: {exc}") + if exc.http_status: + print(f" HTTP status: {exc.http_status}") + if exc.api_code: + print(f" API code : {exc.api_code}") + print() + return False + + except Exception as exc: + print(f" ❌ Errore imprevisto: {exc}") + import traceback + traceback.print_exc() + print() + return False + + +# ─── STEP 3: Test via send_test_message ────────────────────────────────────── + +async def test_send_test_message(bot_token: str, chat_id: str) -> bool: + """ + Testa send_test_message() che invia il messaggio formattato standard. + """ + _sep() + print("STEP 3 – INVIO MESSAGGIO DI TEST FORMATTATO") + _sep() + + from app.notifications.telegram import TelegramError, send_test_message + + print(" Chiamata: send_test_message(channel_name='Test PecFlow')") + print() + + try: + result = await send_test_message( + bot_token=bot_token, + chat_id=chat_id, + channel_name="Test PecFlow", + ) + msg_id = result.get("message_id") + print(f" ✅ INVIO OK – message_id={msg_id}") + print() + return True + + except TelegramError as exc: + print(f" ❌ TelegramError: {exc}") + print() + return False + + +# ─── STEP 4: Test via NotificationService ──────────────────────────────────── + +async def test_via_notification_service(channel_id: str) -> bool: + """ + Testa il flusso completo: NotificationService.test_channel() + che carica dal DB, decifra il token e chiama la Bot API. + """ + _sep() + print("STEP 4 – FLUSSO COMPLETO VIA NotificationService.test_channel()") + _sep() + + import uuid + from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession + from sqlalchemy import text + + # Recupera il tenant_id del canale + db_url = os.environ["DATABASE_URL"] + engine = create_async_engine(db_url) + + try: + async with AsyncSession(engine) as session: + r = await session.execute( + text("SELECT tenant_id FROM notification_channels WHERE id = :id"), + {"id": channel_id}, + ) + row = r.fetchone() + if not row: + print(f" ❌ Canale {channel_id} non trovato nel DB.") + return False + tenant_id = uuid.UUID(str(row[0])) + + print(f" Tenant ID : {tenant_id}") + print(f" Channel ID: {channel_id}") + print() + + from app.services.notification_service import NotificationService + + async with AsyncSession(engine) as session: + service = NotificationService(session) + result = await service.test_channel( + channel_id=uuid.UUID(channel_id), + tenant_id=tenant_id, + ) + + if result.success: + print(f" ✅ test_channel OK") + print(f" Messaggio: {result.message}") + print(f" HTTP status: {result.http_status}") + else: + print(f" ❌ test_channel FALLITO") + print(f" Motivo: {result.message}") + print(f" HTTP status: {result.http_status}") + + print() + return result.success + + except Exception as exc: + print(f" ❌ Errore: {exc}") + import traceback + traceback.print_exc() + return False + + +# ─── Main ───────────────────────────────────────────────────────────────────── + +async def main() -> None: + # Controlla se le credenziali sono passate via env (override manuale) + manual_bot_token = os.environ.get("TELEGRAM_BOT_TOKEN") + manual_chat_id = os.environ.get("TELEGRAM_CHAT_ID") + + if manual_bot_token and manual_chat_id: + channel_id = None + bot_token = manual_bot_token + chat_id = manual_chat_id + print("⚙️ Usando credenziali da variabili d'ambiente TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID") + print() + else: + # Carica dal DB + db_result = await load_channel_from_db() + if not db_result: + print("❌ Impossibile procedere: nessun canale Telegram configurato.") + sys.exit(1) + channel_id, bot_token, chat_id = db_result + + _banner(_mask_token(bot_token), chat_id) + + results: list[tuple[str, bool]] = [] + + # STEP 2: invio diretto + ok = await test_direct_send(bot_token, chat_id) + results.append(("Invio diretto (send_message)", ok)) + + # STEP 3: messaggio formattato + ok = await test_send_test_message(bot_token, chat_id) + results.append(("Messaggio di test formattato (send_test_message)", ok)) + + # STEP 4: flusso completo via NotificationService (solo se abbiamo il channel_id dal DB) + if channel_id: + ok = await test_via_notification_service(channel_id) + results.append(("Flusso completo (NotificationService.test_channel)", ok)) + + # ── Riepilogo ──────────────────────────────────────────────────────────── + _sep("═") + print(" RIEPILOGO TEST TELEGRAM") + _sep("═") + all_ok = True + for name, success in results: + icon = "✅" if success else "❌" + print(f" {icon} {name}") + if not success: + all_ok = False + _sep("═") + print() + + if all_ok: + print("🎉 Tutti i test Telegram superati con successo!") + else: + print("⚠️ Alcuni test Telegram sono falliti.") + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main())