First commit

This commit is contained in:
unknown
2026-06-09 21:18:13 -03:00
commit 5bff6b938b
66 changed files with 10922 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# Exportar los módulos principales
from .database import Base, get_db
from . import models
from . import schemas
#from . import crud
#__all__ = ["Base", "get_db", "models", "schemas", "crud"]
__all__ = ["Base", "get_db", "models", "schemas"]
+38
View File
@@ -0,0 +1,38 @@
[alembic]
# path to migration scripts
script_location = alembic
# sqlalchemy url; will be overridden by env var if present
sqlalchemy.url = driver://user:pass@localhost/dbname
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
+63
View File
@@ -0,0 +1,63 @@
from logging.config import fileConfig
import os
from sqlalchemy import engine_from_config, pool
from alembic import context
config = context.config
fileConfig(config.config_file_name)
import sys
sys.path.insert(0, '/app')
from models import Base
target_metadata = Base.metadata
def _get_url():
# first try environment variable, then fall back to config file
return os.getenv("DATABASE_URL") or config.get_main_option("sqlalchemy.url")
def run_migrations_offline():
"""Run migrations in "offline" mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = _get_url()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in "online" mode."""
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
url=_get_url(),
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+25
View File
@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: str = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
+64
View File
@@ -0,0 +1,64 @@
"""
app/audit.py
Helper centralizado para registrar eventos de auditoría.
Se llama desde los routers después de cada operación exitosa.
"""
import json
from datetime import datetime
from typing import Any, Optional
from sqlalchemy.orm import Session
import models
def _serialize(obj: Any) -> Optional[str]:
"""Serializa un objeto a JSON string. Devuelve None si obj es None."""
if obj is None:
return None
if hasattr(obj, '__dict__'):
# SQLAlchemy model — serializar atributos no privados
data = {
k: v for k, v in obj.__dict__.items()
if not k.startswith('_')
}
# Convertir datetime a string para que sea serializable
for key, value in data.items():
if isinstance(value, datetime):
data[key] = value.isoformat()
return json.dumps(data, default=str)
if isinstance(obj, dict):
return json.dumps(obj, default=str)
return str(obj)
def log_action(
db: Session,
entity_type: str,
entity_id: Any,
action: str,
user_id: Optional[Any] = None,
before: Any = None,
after: Any = None,
ip_address: Optional[str] = None,
) -> None:
# Convertir "system" (feeder) a -1, y cualquier valor no numérico también
if user_id == "system" or user_id is None:
resolved_user_id = -1
else:
try:
resolved_user_id = int(user_id)
except (ValueError, TypeError):
resolved_user_id = -1
entry = models.AuditLog(
entity_type=entity_type,
entity_id=str(entity_id),
action=action,
user_id=resolved_user_id,
before_value=_serialize(before),
after_value=_serialize(after),
timestamp=datetime.utcnow(),
ip_address=ip_address,
)
db.add(entry)
+71
View File
@@ -0,0 +1,71 @@
"""
auth.py:
Métodos y variables globales necesarias para la autenticación y auditoria.
Notas:
- El microservicio requiere que la aplicación que lo utilice comparta su SECRET_KEY
- El microservicio requiere que la aplicación que lo utilice implemente un campo "user_id" en su SECRET_KEY y una expiración: exp
* No se recomienda implementar dos sistemas que manipulen este token en simultaneo, puede provocar incongruencias en el módulo de auditoria. *
"""
import os
from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from datetime import datetime, timedelta
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
SECRET_KEY = os.getenv("SECRET_KEY")
if not SECRET_KEY:
raise RuntimeError("SECRET_KEY no configurada. Abortando inicio.")
if len(SECRET_KEY) < 20:
raise RuntimeError("SECRET_KEY demasiado corta")
ALGORITHM = "HS256"
def get_current_user(token: str = Depends(oauth2_scheme)):
"""
Obtiene un ID de usuario del JWT para auditoria.
"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id = payload.get("user_id")
if not user_id:
raise HTTPException(status_code=401, detail="Token inválido")
return user_id # simplemente el id
except JWTError:
raise HTTPException(status_code=401, detail="Token inválido")
def get_system_token() -> str:
"""Genera o renueva el token de sistema para uso interno."""
payload = {
"user_id": "system",
"exp": datetime.utcnow() + timedelta(hours=24)
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
# Token global para reutilizar
_system_token: str = None
def get_internal_headers() -> dict:
"""Headers con token de sistema para llamadas internas."""
global _system_token
# Renovar si no existe o está por vencer
if _system_token is None or _token_expiring_soon(_system_token):
_system_token = get_system_token()
return {
"Content-Type": "application/json",
"Authorization": f"Bearer {_system_token}"
}
def _token_expiring_soon(token: str) -> bool:
"""
Verifica la expiración del token.
"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
exp = datetime.fromtimestamp(payload["exp"])
return exp - datetime.utcnow() < timedelta(hours=1)
except Exception:
return True # si falla, renovar
+35
View File
@@ -0,0 +1,35 @@
"""
database.py
Contiene configuración y dependencias para manipular la base de datos.
"""
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from dotenv import load_dotenv
import os
load_dotenv()
SQLALCHEMY_DATABASE_URL = os.getenv("DATABASE_URL")
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
pool_size=10, # Aumenta el tamaño del pool
max_overflow=20, # Permite conexiones adicionales
pool_timeout=30 # Tiempo de espera para obtener conexión
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
# Dependency para inyectar la sesión de BD
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+193
View File
@@ -0,0 +1,193 @@
"""
api_implementations.py
Contiene métodos utilizados por el alimentador a lo largo de "chats.py" para manipular los endpoints del sistema.
"""
from dotenv import load_dotenv
import os
import requests
from fastapi import APIRouter, HTTPException, status
from typing import Dict, Optional
from auth import get_internal_headers
##TODO EL QUILOMBO NUEVO##
API_BASE_URL = os.getenv('API_URL')
TIMEOUT = 5.0
router = APIRouter()
load_dotenv()
def patch_api_sync(validated_data: Dict, endpoint: str) -> Optional[Dict]:
"""Función síncrona para hacer PUT a la API"""
try:
response = requests.patch(
f"{API_BASE_URL}/{endpoint}/",
json=validated_data,
headers=get_internal_headers(),
timeout=TIMEOUT
)
if response.status_code == status.HTTP_400_BAD_REQUEST:
error_detail = response.json().get("detail", "")
return error_detail
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Connection failed: {str(e)}"
)
def post_api_sync(validated_data: Dict, endpoint: str) -> Optional[Dict]:
"""Función síncrona para hacer POST a la API"""
try:
response = requests.post(
f"{API_BASE_URL}/{endpoint}/",
json=validated_data,
headers=get_internal_headers(),
timeout=TIMEOUT
)
if response.status_code == status.HTTP_400_BAD_REQUEST:
error_detail = response.json().get("detail", "")
if "already exists" in error_detail:
return None
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Connection failed: {str(e)}"
)
def get_api_sync(endpoint: str):
"""Función síncrona para hacer GET a la API"""
try:
response = requests.get(
f"{API_BASE_URL}/{endpoint}/",
headers=get_internal_headers()
)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Connection failed: {str(e)}"
)
def create_telegram_group(group_data: Dict) -> Optional[Dict]:
"""Crea un grupo en la API remota (versión síncrona)"""
try:
validated_data = {
"id_telegram": group_data["id_telegram"],
"name": group_data["name"],
"description": group_data["description"],
"type": group_data["type"],
"message_position": 0
}
return post_api_sync(validated_data, "groups")
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid group data: {str(e)}"
)
def refresh_telegram_group(channel: int, group_data: Dict) -> Optional[Dict]:
"""Crea un grupo en la API remota (versión síncrona)"""
try:
validated_data = {
"name": str(group_data["name"]),
"description": str(group_data["description"]),
"type": str(group_data["type"])
}
print(f"La data que vamos intentar cargar es {validated_data}")
return patch_api_sync(validated_data, f"groups/{channel}")
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid group data: {str(e)}"
)
def act_message_posittion(group_id: int, message_id: int):
try:
validated_data = {
"message_position": message_id
}
return patch_api_sync(validated_data, f"groups/{group_id}/update-position")
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid group data: {str(e)}"
)
def create_sender(sender: Dict) -> Optional[Dict]:
"""Crea un mensaje en la API remota (versión síncrona)"""
try:
sender_data = {
"id_telegram": sender["id_telegram"],
"type": sender["type"],
"username": sender["username"],
"first_name": str(sender["first_name"]),
"last_name": str(sender["last_name"]),
"phone": str(sender["phone"])
}
print(sender_data)
return post_api_sync(sender_data, "senders")
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid group data: {str(e)}"
)
def create_message(message: Dict) -> Optional[Dict]:
"""Crea un mensaje en la API remota (versión síncrona)"""
try:
message_data = {
"id_mess_g": message["id_mess_g"],
"content": str(message["content"]),
"date": message["date"],
"sender_id": message["sender_id"],
"group_id": message["group_id"]
}
response = post_api_sync(message_data, "messages")
if response is None:
return None
if message['attachments'] is not None:
try:
create_attachment(message['attachments'])
except Exception as e:
print(f"Failed to create attachment for message {response['id_mess_g']}: {str(e)}")
return response
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid group data: {str(e)}"
)
def create_attachment(attachment: Dict) -> Optional[Dict]:
"""Crea un mensaje en la API remota (versión síncrona)"""
try:
attachment_data = {
"message_id": attachment["message_id"],
"type": attachment["type"],
"description": str(attachment["description"]),
"isDownloaded": "false",
"group_id": attachment["group_id"]
}
print(f"Trying to load attachment data: {attachment_data}")
return post_api_sync(attachment_data, "attachments")
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid group data: {str(e)}"
)
+833
View File
@@ -0,0 +1,833 @@
"""
Chats.py
Contiene la lógica del alimentador.
"""
import asyncio
import glob
import os
import time
import threading
from threading import Lock
from typing import Optional, List, Dict
import shutil
import tempfile
from unittest import result
from dotenv import load_dotenv
from telethon import TelegramClient, functions
from telethon.errors import MediaEmptyError
from telethon.tl.types import (
Document, MessageMediaPhoto, MessageMediaDocument, MessageMediaWebPage,
MessageMediaContact, MessageMediaGeo, MessageMediaVenue, MessageMediaGame,
MessageMediaInvoice, DocumentAttributeAudio, DocumentAttributeVideo,
DocumentAttributeSticker, DocumentAttributeAnimated, DocumentAttributeFilename,
Channel, Chat
)
from integrations.api_implementations import (
get_api_sync, post_api_sync, create_sender, create_message,
act_message_posittion, refresh_telegram_group
)
from io import BytesIO
class TelegramClientManager:
"""Responsable únicamente de la conexión, autenticación y ciclo de vida del cliente."""
def __init__(self):
load_dotenv()
self.api_id = os.getenv("TELEGRAM_API_ID")
self.api_hash = os.getenv("TELEGRAM_API_HASH")
self.download_path = os.getenv("DOWNLOAD_PATH", "downloads")
self.client: Optional[TelegramClient] = None
self.is_authorized = False
self.session_path = None
def get_sessions_file(self):
"""Busca el archivo de sesión más reciente en telegram_sessions/."""
session_dir = os.path.join(os.getcwd(), "telegram_sessions")
session_files = glob.glob(os.path.join(session_dir, "session_*.session"))
if not session_files:
raise RuntimeError("No se encontró ningún archivo de sesión en telegram_sessions/")
return max(session_files, key=os.path.getmtime)
def connect(self):
"""
Utiliza un archivo de sesión existente para autenticar una sesión de Telegram mediante un cliente de Telethon.
"""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
session_path = self.get_sessions_file().replace(".session", "")
print(f"[INFO] Usando sesión: {session_path}")
self.client = TelegramClient(
session_path,
self.api_id,
self.api_hash,
connection_retries=5,
retry_delay=5,
auto_reconnect=True
)
# start() en vez de connect() — inicializa el loop interno correctamente
self.client.start()
self.is_authorized = self.client.is_user_authorized()
if self.is_authorized:
print("[INFO] Cliente Telegram conectado y autorizado")
else:
print("[WARN] Sesión no autorizada")
def is_active(self) -> bool:
"""Verifica si la sesión es válida"""
session_filename = self.get_sessions_file()
session_src = f"{session_filename}"
if not os.path.exists(session_src):
raise RuntimeError(f"Session file no encontrado: {session_src}")
session_dir = os.path.dirname(session_filename)
tmp_session = tempfile.mktemp(prefix="val_session_", dir=session_dir)
shutil.copy2(session_src, f"{tmp_session}.session")
result = {"is_active": False, "error": None}
def _validate_in_thread():
tmp_client = None
tmp_loop = asyncio.new_event_loop()
asyncio.set_event_loop(tmp_loop)
try:
tmp_client = TelegramClient(
tmp_session,
self.api_id,
self.api_hash,
loop=tmp_loop,
)
tmp_loop.run_until_complete(tmp_client.connect())
if not tmp_loop.run_until_complete(tmp_client.is_user_authorized()):
result["error"] = "Sesión no autorizada"
result["is_active"] = False
return
result["is_active"] = True
print("[is_active] OK — Sesión válida")
except Exception as e:
result["error"] = str(e)
result["is_active"] = False
print(f"[is_active] ERROR: {type(e).__name__}: {e}")
finally:
if tmp_client:
try:
tmp_loop.run_until_complete(tmp_client.disconnect())
except Exception:
pass
asyncio.set_event_loop(None)
try:
tmp_loop.close()
except Exception:
pass
tmp_file = f"{tmp_session}.session"
if os.path.exists(tmp_file):
try:
os.remove(tmp_file)
except Exception:
pass
thread = threading.Thread(target=_validate_in_thread, daemon=True)
thread.start()
thread.join(timeout=30)
if thread.is_alive():
result["error"] = "Timeout al validar la sesión en Telegram"
result["is_active"] = False
print(result)
return result["is_active"]
def get_client(self) -> Optional[TelegramClient]:
"""Devuelve el cliente, reconectando si es necesario."""
if self.client is None:
self.connect()
return self.client
if not self.client.is_connected():
print("[INFO] Cliente desconectado, reconectando...")
self.client.connect()
time.sleep(1)
self.is_authorized = self.client.is_user_authorized()
return self.client
def disconnect(self):
"""Desconecta el cliente de forma segura."""
if self.client and self.client.is_connected():
self.client.disconnect()
print("[INFO] Cliente Telegram desconectado")
def __del__(self):
self.disconnect()
class DataTransformer:
"""Métodos estáticos para transformar datos de Telethon a dicts."""
@staticmethod
def get_message_attachment_info(message, message_id: int, group_id: int) -> Optional[Dict]:
"""Extrae y estructura la información del adjunto de un mensaje."""
if not hasattr(message, 'media') or not message.media:
return None
media = message.media
info = {
'message_id': message_id,
'group_id': group_id,
'type': None,
'description': None,
'isDownloaded': False
}
if isinstance(media, MessageMediaPhoto):
info.update({
'type': 'photo',
'description': f'Photo (ID: {media.photo.id})'
})
elif isinstance(media, MessageMediaDocument):
doc = media.document
if isinstance(doc, Document):
attr_map = {
DocumentAttributeAudio: lambda a: ('voice', 'Voice message') if getattr(a, 'voice', False) else ('audio', 'Audio'),
DocumentAttributeVideo: lambda a: ('video', 'Video'),
DocumentAttributeSticker: lambda a: ('sticker', 'Sticker'),
DocumentAttributeAnimated: lambda a: ('gif', 'GIF'),
}
tipo = None
descripcion = None
for attr in doc.attributes:
for attr_class, resolver in attr_map.items():
if isinstance(attr, attr_class):
tipo, descripcion = resolver(attr)
break
if tipo:
break
if not tipo:
filename = next(
(a.file_name for a in doc.attributes if isinstance(a, DocumentAttributeFilename)),
doc.mime_type
)
tipo, descripcion = 'document', f'Document ({filename})'
info.update({'type': tipo, 'description': descripcion})
elif isinstance(media, MessageMediaWebPage):
url = getattr(media.webpage, 'url', str(media.webpage))
info.update({'type': 'webpage', 'description': f'URL: {url}'})
elif isinstance(media, MessageMediaContact):
info.update({
'type': 'contact',
'description': f'Contact: {media.first_name} {media.last_name}'
})
elif isinstance(media, MessageMediaGeo):
info.update({
'type': 'location',
'description': f'Location (lat, long): {media.geo.lat}, {media.geo.long}'
})
elif isinstance(media, MessageMediaVenue):
info.update({
'type': 'venue',
'description': f'Place: {media.title} ({media.address})'
})
elif isinstance(media, MessageMediaGame):
info.update({'type': 'game', 'description': f'Game: {media.game.title}'})
elif isinstance(media, MessageMediaInvoice):
info.update({'type': 'invoice', 'description': f'Invoice: {media.title}'})
else:
info.update({
'type': 'unknown',
'description': f'Unknown media type: {type(media)}'
})
return info
@staticmethod
def get_user_info(client: TelegramClient, user_id: int) -> Optional[Dict]:
"""Obtiene información de un usuario por ID."""
try:
# Sin "with client" para no desconectar el cliente compartido
user = client.loop.run_until_complete(client.get_entity(user_id))
return {
'id_telegram': user_id,
'type': 'bot' if getattr(user, 'bot', False) else 'user',
'username': getattr(user, 'username', None),
'first_name': getattr(user, 'first_name', None),
'last_name': getattr(user, 'last_name', None),
'phone': getattr(user, 'phone', None)
}
except Exception as e:
print(f"Error getting user info for {user_id}: {e}")
return None
class TelegramScraper:
"""Lógica de scraping de canales. No maneja conexión directamente."""
def __init__(self, client_manager: TelegramClientManager):
self.manager = client_manager
self.channel_identifier = None
self.first_id = 0
self.ChannelMessages = []
@property
def client(self) -> TelegramClient:
"""Siempre devuelve un cliente conectado."""
return self.manager.get_client()
# --- Helpers de paginación ---
def _last_message_id(self, channel_messages) -> int:
try:
return channel_messages.messages[0].id
except IndexError:
return 0
def _first_message_id(self, channel_messages) -> int:
try:
return channel_messages.messages[-1].id
except IndexError:
return 0
# --- Configuración de canal ---
def set_chat_id(self, channel: str):
try:
if '-' in channel:
self.channel_identifier = int(channel)
else:
self.channel_identifier = channel
except Exception as e:
raise ValueError(f"Error resolving channel: {e}")
def get_channel_info(self) -> Optional[Dict]:
"""
Obtiene información de la entidad y determina su tipo correctamente.
Tipos de entidad en Telethon:
- Channel, megagroup=False → canal (público o privado)
- Channel, megagroup=True → supergrupo (usa prefijo -100)
- Chat → grupo normal (NO usa prefijo -100)
"""
try:
entity = self.client.loop.run_until_complete(
self.client.get_entity(self.channel_identifier)
)
username = getattr(entity, 'username', None)
is_public = bool(username)
entity_id = getattr(entity, 'id', None)
title = getattr(entity, 'title', '')
description = getattr(entity, 'about', '')
if isinstance(entity, Channel):
if entity.megagroup:
# Supergrupo — usa prefijo -100
chat_type = 'public_supergroup' if is_public else 'private_supergroup'
else:
# Canal puro
chat_type = 'public_channel' if is_public else 'private_channel'
elif isinstance(entity, Chat):
# Grupo normal — NO usa prefijo -100
chat_type = 'group'
else:
# Usuario u otro tipo — tratarlo como privado
chat_type = 'private_channel'
return {
'type': chat_type,
'is_group': isinstance(entity, Chat),
'is_megagroup': isinstance(entity, Channel) and entity.megagroup,
'username': username or 'private',
'id': entity_id,
'title': title,
'description': description,
}
except Exception as e:
print(f"Error getting channel info: {e}")
if "authorization" in str(e).lower():
self.manager.is_authorized = False
return None
# --- Obtención de mensajes ---
def _get_channel_messages(self, limit: int, id_init: int, min_id: int, max_id: int):
"""
Obtiene un lote de mensajes de un canal.
"""
try:
return self.client.loop.run_until_complete(
self.client(functions.messages.GetHistoryRequest(
peer=self.channel_identifier,
limit=limit,
offset_date=0,
add_offset=0,
offset_id=id_init,
min_id=min_id,
max_id=max_id,
hash=0
))
)
except Exception as e:
print(f"Error fetching messages: {e}")
if "authorization" in str(e).lower():
self.manager.is_authorized = False
return None
def set_channel_messages(self):
"""Carga todos los mensajes del canal en self.ChannelMessages."""
channel_messages_array = []
first_messages = self._get_channel_messages(1, 0, 0, 0)
self.first_id = self._first_message_id(first_messages)
last_id = self._last_message_id(first_messages)
while last_id != 0:
messages = self._get_channel_messages(100, last_id, 0, 0)
channel_messages_array.append(messages)
last_id = self._first_message_id(messages)
time.sleep(0.5)
print(f"Finished, we made {len(channel_messages_array)} cycles")
self.ChannelMessages = channel_messages_array
def refresh_chat(self):
"""Carga solo los mensajes nuevos desde first_id."""
channel_messages_array = []
first_messages = self._get_channel_messages(1, 0, 0, 0)
last_id = self._last_message_id(first_messages)
while last_id > self.first_id:
messages = self._get_channel_messages(100, last_id, self.first_id, 0)
channel_messages_array.append(messages)
print(f"last id: {last_id}, new last_id: {self._first_message_id(messages)}")
last_id = self._first_message_id(messages)
time.sleep(0.5)
print(f"Finished, we made {len(channel_messages_array)} cycles")
self.ChannelMessages = channel_messages_array
# --- Procesamiento y publicación ---
def _preload_senders(self) -> Dict:
"""Precarga y publica los senders únicos encontrados en los mensajes."""
unique_sender_ids = {
msg.sender_id
for channel_message in self.ChannelMessages
for msg in channel_message.messages
if hasattr(msg, 'sender_id') and msg.sender_id
}
print(f"🔍 {len(unique_sender_ids)} usuarios únicos encontrados")
users_cache = {}
for sender_id in unique_sender_ids:
user_info = DataTransformer.get_user_info(self.client, sender_id)
if user_info:
users_cache[sender_id] = user_info
try:
exists = get_api_sync(f"senders/{sender_id}")
except Exception:
exists = None
if exists is None:
create_sender(user_info)
return users_cache
def get_and_post_message_info(self) -> List[Dict]:
"""Procesa y publica todos los mensajes cargados en self.ChannelMessages."""
users_cache = self._preload_senders()
msgs = []
for channel_message in self.ChannelMessages:
for msg in channel_message.messages:
sender_id = getattr(msg, 'sender_id', None)
message = {
'id_mess_g': msg.id,
'content': getattr(msg, 'message', ''),
'date': msg.date.isoformat(),
'attachments': DataTransformer.get_message_attachment_info(
msg, msg.id, self.channel_identifier
),
'sender_id': sender_id,
'group_id': self.channel_identifier
}
try:
create_message(message)
msgs.append(message)
except Exception as e:
print(f"Error creating message: {str(e)}")
if msgs:
act_message_posittion(self.channel_identifier, msgs[-1]["id_mess_g"])
else:
print("The channel has not pending messages")
return msgs
def get_message_info(self) -> List[Dict]:
"""Devuelve los mensajes estructurados sin publicarlos en la API para debug."""
users_cache = self._preload_senders()
msgs = []
for channel_message in self.ChannelMessages:
for msg in channel_message.messages:
sender_id = getattr(msg, 'sender_id', None)
msgs.append({
'id_mess_g': msg.id,
'content': getattr(msg, 'message', ''),
'date': msg.date.isoformat(),
'attachments': DataTransformer.get_message_attachment_info(
msg, msg.id, self.channel_identifier
),
'sender': users_cache.get(sender_id, {})
})
return msgs
# --- Gestión de canales ---
def add_chat(self, channel: str) -> Dict:
"""
Agrega o actualiza un canal/grupo en la API.
Construye el ID negativo correcto según el tipo:
- Canal y Supergrupo → -100{id}
- Grupo normal → -{id} (sin el 100)
"""
self.set_chat_id(channel)
info = self.get_channel_info()
if not info:
raise ValueError(f"Channel/group {channel} not found or inaccessible")
entity_id = info['id']
# Construir el ID negativo correcto según el tipo
if info.get('is_group'):
# Grupo normal: ID negativo simple
if not str(entity_id).startswith('-'):
entity_id = f"-{entity_id}"
else:
# Canal o supergrupo: prefijo -100
if not str(entity_id).startswith('-100'):
entity_id = f"-100{entity_id}"
info['id'] = str(entity_id)
new_group = {
"name": info.get('username') if info.get('username') != 'private'
else info.get('title', f"group_{entity_id}"),
"description": info.get('description') or info.get('title', ''),
"type": info['type']
}
print(f"[add_chat] id={entity_id}, type={info['type']}, group_data={new_group}")
created_group = refresh_telegram_group(entity_id, new_group)
if created_group:
return {
"status": "success",
"group": created_group,
"message": "Group updated successfully"
}
return {"status": "error", "message": f"Error updating {channel}"}
def add_chats(self):
"""Actualiza la información de todos los grupos almacenados."""
chats = get_api_sync("groups")
print(chats)
for chat in chats:
self.add_chat(str(chat['id_telegram']))
def feeder_loop(self):
"""Ciclo principal de alimentación de datos."""
print("Init feeder loop")
inicio = time.time()
groups = get_api_sync("groups")
for group in groups:
channel_id = group["id_telegram"]
self.set_chat_id(str(channel_id))
info = self.get_channel_info()
print(f"Channel info: {info}")
self.first_id = group["message_position"]
self.refresh_chat()
self.get_and_post_message_info()
fin = time.time()
print(f"Tiempo empleado: {fin - inicio:.2f} segundos")
# --- Descarga de adjuntos ---
def download_attachment_to_buffer(self, group_id: int, message_id: int) -> BytesIO:
"""
Descarga el media de un mensaje de Telegram a un BytesIO.
Crea una sesión Telethon temporal sobre una copia del session file,
en un thread dedicado para no interferir con el loop del scraper.
"""
session_filename = self.manager.get_sessions_file()
session_src = f"{session_filename}"
if not os.path.exists(session_src):
raise RuntimeError(f"Session file no encontrado: {session_src}")
session_dir = os.path.dirname(session_filename)
tmp_session = tempfile.mktemp(prefix="dl_session_", dir=session_dir)
shutil.copy2(session_src, f"{tmp_session}.session")
print(f"[DL] Session copiada -> {tmp_session}.session")
result = {"buffer": None, "error": None}
def _download_in_thread():
tmp_client = None
tmp_loop = asyncio.new_event_loop()
asyncio.set_event_loop(tmp_loop)
try:
tmp_client = TelegramClient(
tmp_session,
self.manager.api_id,
self.manager.api_hash,
loop=tmp_loop,
)
# connect() en lugar de start() — no dispara prompts interactivos
tmp_loop.run_until_complete(tmp_client.connect())
if not tmp_loop.run_until_complete(tmp_client.is_user_authorized()):
raise RuntimeError("La sesión copiada no está autorizada")
print(f"[DL] Cliente temporal conectado y autorizado")
message = tmp_loop.run_until_complete(
tmp_client.get_messages(group_id, ids=message_id)
)
if message is None:
raise ValueError(f"Mensaje {message_id} no encontrado en grupo {group_id}")
if not message.media:
raise ValueError(f"El mensaje {message_id} no tiene media adjunta")
print(f"[DL] Mensaje encontrado, media={type(message.media).__name__}")
buffer = BytesIO()
tmp_loop.run_until_complete(
tmp_client.download_media(message, file=buffer)
)
buffer.seek(0)
print(f"[DL] Descarga OK, tamaño={buffer.getbuffer().nbytes} bytes")
result["buffer"] = buffer
except Exception as e:
print(f"[DL] ERROR en thread: {type(e).__name__}: {e}")
result["error"] = e
finally:
if tmp_client is not None:
try:
if not tmp_loop.is_closed():
tmp_loop.run_until_complete(tmp_client.disconnect())
except Exception as e:
print(f"[DL] ERROR desconectando cliente temporal: {type(e).__name__}: {e}")
try:
asyncio.set_event_loop(None)
except Exception:
pass
try:
tmp_loop.close()
except Exception as e:
print(f"[DL] ERROR cerrando loop temporal: {type(e).__name__}: {e}")
tmp_session_file = f"{tmp_session}.session"
if os.path.exists(tmp_session_file):
try:
os.remove(tmp_session_file)
print(f"[DL] Session temporal eliminada: {tmp_session_file}")
except Exception as e:
print(f"[DL] No se pudo eliminar session temporal: {e}")
thread = threading.Thread(target=_download_in_thread, daemon=True)
thread.start()
thread.join(timeout=600)
if thread.is_alive():
raise RuntimeError("Timeout: la descarga tardó más de 10 minutos")
if result["error"] is not None:
raise result["error"]
if result["buffer"] is None:
raise RuntimeError("La descarga no produjo contenido")
return result["buffer"]
def validate_chat(self, channel_id: int) -> Dict:
"""
Valida que un canal/grupo exista y sea accesible en Telegram,
y retorna su información sin guardarlo en la DB.
Usa sesión temporal (mismo patrón que download_attachment_to_buffer)
para no interferir con el loop del scraper.
Returns:
dict con 'valid': True/False y, si válido, los campos de info.
Raises:
RuntimeError si no se puede obtener el session file.
"""
session_filename = self.session_path
session_src = f"{session_filename}"
if not os.path.exists(session_src):
raise RuntimeError(f"Session file no encontrado: {session_src}")
session_dir = os.path.dirname(session_filename)
tmp_session = tempfile.mktemp(prefix="val_session_", dir=session_dir)
shutil.copy2(session_src, f"{tmp_session}.session")
result = {"valid": False, "error": None, "info": None}
def _validate_in_thread():
tmp_client = None
tmp_loop = asyncio.new_event_loop()
asyncio.set_event_loop(tmp_loop)
try:
tmp_client = TelegramClient(
tmp_session,
self.manager.api_id,
self.manager.api_hash,
loop=tmp_loop,
)
tmp_loop.run_until_complete(tmp_client.connect())
if not tmp_loop.run_until_complete(tmp_client.is_user_authorized()):
result["error"] = "Sesión no autorizada"
return
entity = tmp_loop.run_until_complete(
tmp_client.get_entity(channel_id)
)
username = getattr(entity, 'username', None)
is_public = bool(username)
entity_id = getattr(entity, 'id', None)
title = getattr(entity, 'title', '')
description = getattr(entity, 'about', '')
if isinstance(entity, Channel):
if entity.megagroup:
chat_type = 'public_supergroup' if is_public else 'private_supergroup'
else:
chat_type = 'public_channel' if is_public else 'private_channel'
# Construir ID con prefijo -100
full_id = int(f"-100{entity_id}") if entity_id else channel_id
elif isinstance(entity, Chat):
chat_type = 'group'
full_id = -abs(entity_id) if entity_id else channel_id
else:
result["error"] = f"Tipo de entidad no soportado: {type(entity).__name__}"
return
result["valid"] = True
result["info"] = {
"id_telegram": full_id,
"type": chat_type,
"username": username or title or str(full_id),
"title": title,
"description": description,
}
print(f"[validate_chat] OK — id={full_id}, type={chat_type}, title={title}")
except Exception as e:
result["error"] = str(e)
print(f"[validate_chat] ERROR: {type(e).__name__}: {e}")
finally:
if tmp_client:
try:
tmp_loop.run_until_complete(tmp_client.disconnect())
except Exception:
pass
asyncio.set_event_loop(None)
try:
tmp_loop.close()
except Exception:
pass
tmp_file = f"{tmp_session}.session"
if os.path.exists(tmp_file):
try:
os.remove(tmp_file)
except Exception:
pass
thread = threading.Thread(target=_validate_in_thread, daemon=True)
thread.start()
thread.join(timeout=30)
if thread.is_alive():
result["error"] = "Timeout al validar el canal en Telegram"
return result
class TelegramChatSingleton:
"""Singleton thread-safe. Expone el scraper y el cliente."""
_manager: Optional[TelegramClientManager] = None
_scraper: Optional[TelegramScraper] = None
_lock = Lock()
def __new__(cls):
raise RuntimeError("Use get_scraper() or get_client() classmethods")
@classmethod
def get_tmp_scraper(cls) -> TelegramScraper:
"""Crea y devuelve una instancia temporal de TelegramScraper con su propio manager."""
tmp_manager = TelegramClientManager()
if tmp_manager.is_active():
return TelegramScraper(tmp_manager)
else:
raise RuntimeError("No se pudo crear el scraper temporal: sesión no válida")
@classmethod
def get_scraper(cls) -> TelegramScraper:
"""Devuelve la instancia única del scraper, creándola si no existe."""
if cls._scraper is None:
with cls._lock:
if cls._scraper is None:
cls._manager = TelegramClientManager()
cls._manager.connect()
cls._scraper = TelegramScraper(cls._manager)
return cls._scraper
@classmethod
def get_client(cls) -> Optional[TelegramClient]:
"""Devuelve el cliente activo si existe. NO intenta conectar."""
if cls._manager is None:
return None
return cls._manager.client # acceso directo, sin llamar get_client() del manager
@classmethod
def cleanup(cls):
"""Limpia la instancia (útil para tests o reinicio manual)."""
with cls._lock:
if cls._manager:
cls._manager.disconnect()
cls._manager = None
cls._scraper = None
View File
+150
View File
@@ -0,0 +1,150 @@
"""
Main.py
Contiene métodos principales del sistema.
"""
import datetime
from xmlrpc import client
from fastapi import FastAPI
import models
from database import engine
from routers import groups, messages, senders, attachments, rules, alerts, mannage, notes, audit, stats
from integrations.chats import TelegramChatSingleton
import asyncio
import time
models.Base.metadata.create_all(bind=engine)
app = FastAPI(
title="Telegram Monitor API",
description="API para monitoreo de mensajes de Telegram",
version="0.1.0"
)
app.include_router(groups.router)
app.include_router(messages.router)
app.include_router(senders.router)
app.include_router(attachments.router)
app.include_router(rules.router)
app.include_router(alerts.router)
app.include_router(notes.router)
app.include_router(mannage.router)
app.include_router(audit.router)
app.include_router(stats.router)
@app.get("/")
def read_root():
return {"message": "Bienvenido a la API de Monitor de Telegram"}
@app.get("/health")
def health_check():
return {"status": "healthy"}
""""
Diccionario para almacenar las variables de estado.
"""
scraper_status = {
"is_active": False,
"last_cycle": None,
"last_error": None,
}
"""
Configuración del alimentador:
* Se recomienda no tener un cicle_time acotado para evitar bloqueos por "Flood" a la API de Telegram.
* Error_delay representa el tiempo a esperar hasta que se reinicie el alimentador tras un error en el ciclo.
"""
scraper_config = {
"cicle_time": 200,
"error_delay": 60,
}
@app.get("/telegram-status")
def telegram_status():
"""
Verificar estado del cliente del bucle principal
Reintenta la conexión desde un hilo nuevo para verificar si el archivo de sesión aún es válido
Modifica "is_active" y "last_error" dependiendo del estado de la sesión
"""
manager = TelegramChatSingleton._manager
# Verificar si el manager y cliente existen
if manager is None:
status = {
"is_active": False,
"last_cycle": scraper_status.get("last_cycle"),
"last_error": "Cliente de Telegram no inicializado",
}
print(status)
return status
# Verificar estado de la sesión con timeout
try:
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(manager.is_active)
try:
is_active = future.result(timeout=5)
scraper_status["is_active"] = is_active
if is_active:
scraper_status["last_error"] = None
else:
scraper_status["last_error"] = "Sesión no autorizada"
except concurrent.futures.TimeoutError:
scraper_status["is_active"] = False
scraper_status["last_error"] = "Timeout verificando estado de sesión"
except Exception as e:
scraper_status["is_active"] = False
scraper_status["last_error"] = f"Error verificando estado: {str(e)}"
except Exception as e:
scraper_status["is_active"] = False
scraper_status["last_error"] = f"Error inesperado: {str(e)}"
status = {
"is_active": scraper_status["is_active"],
"last_cycle": scraper_status.get("last_cycle"),
"last_error": scraper_status.get("last_error"),
}
print(status)
return status
def run_sync_scraper():
"""
Inicializa el alimentador,
"""
while True:
try:
scraper = TelegramChatSingleton.get_scraper()
scraper_status["is_active"] = (
scraper.client is not None and scraper.client.is_connected()
)
scraper_status["last_error"] = None
print("Updatring Telegram Groups")
scraper.add_chats()
print("Feeding Groups")
scraper.feeder_loop()
scraper_status["last_cycle"] = datetime.datetime.utcnow().isoformat()
print("Cycle completed, waiting 5 minutes")
time.sleep(scraper_config["cicle_time"])
except Exception as e:
print(f"Error in scraper: {e}")
scraper_status["is_active"] = False
scraper_status["last_error"] = str(e)
# Ya no reseteamos scraper=None — el Singleton y el manager
# manejan la reconexión internamente en get_client()
time.sleep(scraper_config["error_delay"])
@app.on_event("startup")
async def startup_event():
asyncio.create_task(asyncio.to_thread(run_sync_scraper))
+180
View File
@@ -0,0 +1,180 @@
"""
Models.py
Contiene los modelos de sqlalchemy para representar las entidades, campos y relaciones en la base de datos.
"""
from sqlalchemy import Integer, String, DateTime, ForeignKey, Boolean, BIGINT, ForeignKeyConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
from typing import Optional, List
from database import Base
class Group(Base):
"""
Entidad Grupos:
* Se identifica por un ID de Telegram
* Contiene de uno a muchos mensajes.
"""
__tablename__ = 'groups'
#id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
id_telegram: Mapped[int] = mapped_column(BIGINT, primary_key=True, unique=True)
name: Mapped[str] = mapped_column(String(255), unique=True)
description: Mapped[Optional[str]] = mapped_column(String(255))
type: Mapped[str] = mapped_column(String(30))
message_position: Mapped[int] = mapped_column(BIGINT)
messages: Mapped[List["Message"]] = relationship(back_populates="group")
class Message(Base):
"""
Entidad Mensajes:
* Se identifica por ID de Mensaje en un ID de grupo específico.
* Esta relacionada con un Remitente
* Puede estar relacionado con un adjunto
* Puede estar relacionado con una o muchas alertas.
"""
__tablename__ = 'messages'
#id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
id_mess_g: Mapped[int] = mapped_column(BIGINT, primary_key=True) # ID en el grupo
group_id: Mapped[int] = mapped_column(BIGINT, ForeignKey('groups.id_telegram'), primary_key=True)
content: Mapped[str] = mapped_column(String(4096))
date: Mapped[datetime] = mapped_column(DateTime)
# Foreign keys
sender_id: Mapped[int] = mapped_column(ForeignKey('senders.id_telegram'))
# Relationships
sender: Mapped["Sender"] = relationship(back_populates="messages")
group: Mapped["Group"] = relationship(back_populates="messages")
attachments: Mapped[List["Attachment"]] = relationship(back_populates="message")
alerts: Mapped[List["Alert"]] = relationship(back_populates="message")
class Attachment(Base):
"""
Clase Adjuntos:
* Se identifica según su ID.
* Esta relacionado a un mensaje en un grupo específico.
"""
__tablename__ = 'attachments'
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
type: Mapped[str] = mapped_column(String(50))
description: Mapped[Optional[str]] = mapped_column(String(512))
isDownloaded: Mapped[bool] = mapped_column(Boolean)
message_id: Mapped[int] = mapped_column(BIGINT)
group_id: Mapped[int] = mapped_column(BIGINT)
__table_args__ = (
ForeignKeyConstraint(
['message_id', 'group_id'],
['messages.id_mess_g', 'messages.group_id']
),
)
message: Mapped["Message"] = relationship(
back_populates="attachments",
foreign_keys="[Attachment.message_id, Attachment.group_id]"
)
class Sender(Base):
"""
Entidad "Sender" o Remitente:
* Se identifica según el ID proporcionado por Telegram.
* Esta relacionado con un o muchos mensajes.
"""
__tablename__ = 'senders'
#id: Mapped[int] = mapped_column(Integer, index=True)
id_telegram: Mapped[int] = mapped_column(BIGINT, primary_key=True, unique=True)
type: Mapped[str] = mapped_column(String(10)) # user/channel/chat
username: Mapped[Optional[str]] = mapped_column(String(100))
first_name: Mapped[Optional[str]] = mapped_column(String(100))
last_name: Mapped[Optional[str]] = mapped_column(String(100))
phone: Mapped[Optional[str]] = mapped_column(String(20))
messages: Mapped[List["Message"]] = relationship(back_populates="sender")
# Entidad Reglas:
#
class Rule(Base):
"""
Entidad Regla:
* Se identifica según su ID.
* Una regla puede relacionarse con ninguna, una o múltiples alertas.
"""
__tablename__ = 'rules'
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
description: Mapped[str] = mapped_column(String(255))
regex: Mapped[str] = mapped_column(String(1024))
severity: Mapped[str] = mapped_column(String(20)) # baja/media/alta
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
alerts: Mapped[List["Alert"]] = relationship(back_populates="rule")
class Alert(Base):
"""Entidad Alertas:+
* Una alerta se identifica mediante un mensaje en un grupo en específico
* Una o muchas Alertas se relacionan con una regla.
* Una alerta esta relacionada con ninguna, una o múltiples notas.
"""
__tablename__ = 'alerts'
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
message_id: Mapped[int] = mapped_column(BIGINT)
group_id: Mapped[int] = mapped_column(BIGINT)
rule_id: Mapped[int] = mapped_column(ForeignKey('rules.id'))
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
status: Mapped[str] = mapped_column(String(20), default='open')
notes: Mapped[Optional[str]] = mapped_column(String(500))
__table_args__ = (
ForeignKeyConstraint(
['message_id', 'group_id'],
['messages.id_mess_g', 'messages.group_id']
),
)
message: Mapped["Message"] = relationship(back_populates="alerts")
rule: Mapped["Rule"] = relationship(back_populates="alerts")
notes_list: Mapped[List["Note"]] = relationship(back_populates="alert")
class Note(Base):
"""
Entidad Nota:
* Se identifica según ID de mensaje y ID de grupo específico.
* Una o muchas notas estan relaciónada a una Alerta.
"""
__tablename__ = 'notes'
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
alert_id: Mapped[int] = mapped_column(ForeignKey('alerts.id'), index=True)
user_id: Mapped[int] = mapped_column(Integer)
content: Mapped[str] = mapped_column(String(1000))
creation_date: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
alert: Mapped["Alert"] = relationship(back_populates="notes_list")
# Entidad para almacenar logs de auditoría
class AuditLog(Base):
__tablename__ = 'audit_logs'
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
entity_type: Mapped[str] = mapped_column(String(50)) # rule, alert, group, message, sender
entity_id: Mapped[str] = mapped_column(String(100)) # string para cubrir PKs compuestas
action: Mapped[str] = mapped_column(String(20)) # create, update, delete, status_change
user_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
before_value: Mapped[Optional[str]] = mapped_column(String(4096), nullable=True) # JSON serializado
after_value: Mapped[Optional[str]] = mapped_column(String(4096), nullable=True) # JSON serializado
timestamp: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
View File
+220
View File
@@ -0,0 +1,220 @@
"""
alerts.py
Contiene endpoint para administrar el CRUD y la lógica de las alertas.
"""
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from database import get_db
import models
import schemas
from typing import List, Optional
from datetime import datetime
from auth import get_current_user
from audit import log_action
router = APIRouter()
@router.post("/alerts/", response_model=schemas.AlertResponse, tags=['Alerts'])
def create_alert(
alert: schemas.AlertCreate,
request: Request,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
db_message = db.query(models.Message).filter(
models.Message.id_mess_g == alert.message_id,
models.Message.group_id == alert.group_id
).first()
if not db_message:
raise HTTPException(status_code=404, detail="Message not found")
db_rule = db.query(models.Rule).filter(models.Rule.id == alert.rule_id).first()
if not db_rule:
raise HTTPException(status_code=404, detail="Rule not found")
db_alert = models.Alert(
message_id=alert.message_id,
group_id=alert.group_id,
rule_id=alert.rule_id,
status=alert.status,
notes=alert.notes,
created_at=datetime.utcnow()
)
db.add(db_alert)
db.flush()
log_action(
db=db, entity_type='alert',
entity_id=db_alert.id,
action='create', user_id=current_user,
after=db_alert, ip_address=request.client.host
)
db.commit()
db.refresh(db_alert)
return db_alert
@router.get("/alerts/", response_model=List[schemas.AlertResponse], tags=['Alerts'])
def read_alerts(
skip: int = 0,
limit: int = 100,
status: Optional[str] = None,
severity: Optional[str] = None,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
query = db.query(models.Alert)
if status:
query = query.filter(models.Alert.status == status)
if severity:
query = query.filter(models.Alert.rule.has(models.Rule.severity == severity))
if date_from:
query = query.filter(models.Alert.created_at >= date_from)
if date_to:
query = query.filter(models.Alert.created_at <= date_to)
return query.offset(skip).limit(limit).all()
@router.get("/alerts/{alert_id}", response_model=schemas.AlertResponse, tags=['Alerts'])
def read_alert(
alert_id: int,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
db_alert = db.query(models.Alert).filter(models.Alert.id == alert_id).first()
if not db_alert:
raise HTTPException(status_code=404, detail="Alert not found")
return db_alert
@router.put("/alerts/{alert_id}", response_model=schemas.AlertResponse, tags=['Alerts'])
def update_alert(
alert_id: int,
alert: schemas.AlertCreate,
request: Request,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
db_alert = db.query(models.Alert).filter(models.Alert.id == alert_id).first()
if not db_alert:
raise HTTPException(status_code=404, detail="Alert not found")
before_snapshot = {
'id': db_alert.id,
'message_id': db_alert.message_id,
'group_id': db_alert.group_id,
'rule_id': db_alert.rule_id,
'status': db_alert.status,
'notes': db_alert.notes,
}
for field, value in alert.model_dump().items():
setattr(db_alert, field, value)
log_action(
db=db, entity_type='alert', entity_id=alert_id,
action='update', user_id=current_user,
before=before_snapshot, after=db_alert,
ip_address=request.client.host
)
db.commit()
db.refresh(db_alert)
return db_alert
@router.delete("/alerts/{alert_id}", tags=['Alerts'])
def delete_alert(
alert_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
db_alert = db.query(models.Alert).filter(models.Alert.id == alert_id).first()
if not db_alert:
raise HTTPException(status_code=404, detail="Alert not found")
log_action(
db=db, entity_type='alert', entity_id=alert_id,
action='delete', user_id=current_user,
before=db_alert, ip_address=request.client.host
)
db.delete(db_alert)
db.commit()
return {"message": "Alert deleted successfully"}
@router.post("/alerts/{alert_id}/resolve", tags=['Alerts'])
def resolve_alert(
alert_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
db_alert = db.query(models.Alert).filter(models.Alert.id == alert_id).first()
if not db_alert:
raise HTTPException(status_code=404, detail="Alert not found")
log_action(
db=db, entity_type='alert', entity_id=alert_id,
action='status_change', user_id=current_user,
before={'status': db_alert.status},
after={'status': 'close'},
ip_address=request.client.host
)
db_alert.status = "close"
db.commit()
db.refresh(db_alert)
return db_alert
@router.post("/alerts/{alert_id}/reopen", tags=['Alerts'])
def open_alert(
alert_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
db_alert = db.query(models.Alert).filter(models.Alert.id == alert_id).first()
if not db_alert:
raise HTTPException(status_code=404, detail="Alert not found")
log_action(
db=db, entity_type='alert', entity_id=alert_id,
action='status_change', user_id=current_user,
before={'status': db_alert.status},
after={'status': 'open'},
ip_address=request.client.host
)
db_alert.status = "open"
db.commit()
db.refresh(db_alert)
return db_alert
@router.post("/alerts/{alert_id}/in-progress", tags=['Alerts'])
def set_alert_in_progress(
alert_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
db_alert = db.query(models.Alert).filter(models.Alert.id == alert_id).first()
if not db_alert:
raise HTTPException(status_code=404, detail="Alert not found")
# Solo cambiar si está abierta, no sobreescribir estados más avanzados
if db_alert.status != "open":
return db_alert
log_action(
db=db, entity_type='alert', entity_id=alert_id,
action='status_change', user_id=current_user,
before={'status': db_alert.status},
after={'status': 'in_progress'},
ip_address=request.client.host
)
db_alert.status = "in_progress"
db.commit()
db.refresh(db_alert)
return db_alert
+157
View File
@@ -0,0 +1,157 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from database import get_db
import models
import schemas
from typing import List
from datetime import datetime
from auth import get_current_user
from fastapi.responses import StreamingResponse
from io import BytesIO
from integrations.chats import TelegramChatSingleton
router = APIRouter()
# ATTACHMENT CRUD
@router.post("/attachments/", response_model=schemas.AttachmentResponse, tags=['Attachments'])
def create_attachment(attachment: schemas.AttachmentCreate, db: Session = Depends(get_db)):
# Verificar que el mensaje existe (FK compuesta)
db_message = db.query(models.Message).filter(
models.Message.id_mess_g == attachment.message_id,
models.Message.group_id == attachment.group_id
).first()
if not db_message:
raise HTTPException(status_code=404, detail="Message not found")
db_attachment = models.Attachment(**attachment.model_dump())
db.add(db_attachment)
db.commit()
db.refresh(db_attachment)
return db_attachment
@router.get("/attachments/", response_model=List[schemas.AttachmentResponse], tags=['Attachments'])
def read_attachments(skip: int = 0, limit: int = 100, db: Session = Depends(get_db), current_user: str = Depends(get_current_user)):
attachments = db.query(models.Attachment).offset(skip).limit(limit).all()
return attachments
@router.get("/attachments/{attachment_id}", response_model=schemas.AttachmentResponse, tags=['Attachments'])
def read_attachment(attachment_id: int, db: Session = Depends(get_db), current_user: str = Depends(get_current_user)):
db_attachment = db.query(models.Attachment).filter(models.Attachment.id == attachment_id).first()
if not db_attachment:
raise HTTPException(status_code=404, detail="Attachment not found")
return db_attachment
@router.put("/attachments/{attachment_id}", response_model=schemas.AttachmentResponse, tags=['Attachments'])
def update_attachment(attachment_id: int, attachment: schemas.AttachmentCreate, db: Session = Depends(get_db), current_user: str = Depends(get_current_user)):
db_attachment = db.query(models.Attachment).filter(models.Attachment.id == attachment_id).first()
if not db_attachment:
raise HTTPException(status_code=404, detail="Attachment not found")
for field, value in attachment.model_dump().items():
setattr(db_attachment, field, value)
db.commit()
db.refresh(db_attachment)
return db_attachment
@router.delete("/attachments/{attachment_id}", tags=['Attachments'])
def delete_attachment(attachment_id: int, db: Session = Depends(get_db), current_user: str = Depends(get_current_user)):
db_attachment = db.query(models.Attachment).filter(models.Attachment.id == attachment_id).first()
if not db_attachment:
raise HTTPException(status_code=404, detail="Attachment not found")
db.delete(db_attachment)
db.commit()
return {"message": "Attachment deleted successfully"}
def _download_sync(group_id: int, message_id: int) -> bytes:
"""
Descarga el media de un mensaje de Telegram de forma síncrona.
Pensado para correrse dentro de asyncio.to_thread().
Usa el TelegramChatSingleton existente para no abrir una segunda sesión.
"""
scraper = TelegramChatSingleton.get_scraper()
client = scraper.client # TelegramClient ya conectado y autorizado
# get_messages es una corrutina de Telethon — la corremos en el event loop
# del cliente, que es síncrono en el contexto del scraper
message = client.loop.run_until_complete(
client.get_messages(group_id, ids=message_id)
)
if message is None:
raise ValueError(f"Mensaje {message_id} no encontrado en grupo {group_id}")
if not message.media:
raise ValueError(f"El mensaje {message_id} no tiene media adjunta")
buffer = BytesIO()
client.loop.run_until_complete(
client.download_media(message, file=buffer)
)
buffer.seek(0)
return buffer.read()
@router.get("/attachments/{attachment_id}/download", tags=["Attachments"])
def download_attachment(
attachment_id: int,
db: Session = Depends(get_db),
current_user: str = Depends(get_current_user),
):
db_attachment = db.query(models.Attachment).filter(
models.Attachment.id == attachment_id
).first()
if not db_attachment:
raise HTTPException(status_code=404, detail="Attachment not found")
print(f"[DL] attachment encontrado: id={db_attachment.id}, type={db_attachment.type}, "
f"group_id={db_attachment.group_id}, message_id={db_attachment.message_id}")
try:
scraper = TelegramChatSingleton.get_tmp_scraper()
print(f"[DL] scraper obtenido: {scraper}")
except Exception as e:
msg = f"No se pudo obtener el scraper: {type(e).__name__}: {e}"
print(f"[DL] ERROR — {msg}")
raise HTTPException(status_code=503, detail=msg)
try:
buffer = scraper.download_attachment_to_buffer(
db_attachment.group_id,
db_attachment.message_id
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
msg = f"{type(e).__name__}: {e}"
print(f"[DL] ERROR en download_attachment_to_buffer — {msg}")
raise HTTPException(status_code=503, detail=msg)
content_type_map = {
"photo": ("image/jpeg", "jpg"),
"video": ("video/mp4", "mp4"),
"audio": ("audio/mpeg", "mp3"),
"voice": ("audio/ogg", "ogg"),
"sticker": ("image/webp", "webp"),
"document": ("application/octet-stream", "bin"),
"gif": ("image/gif", "gif"),
"zip": ("application/zip", ".zip"),
"pdf": ("application/pdf", ".pdf"),
"msword": ("application/msword", ".doc"),
"vnd.openxmlformats-officedocument.wordprocessingml.document": ("application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".docx"),
"vnd.ms-excel": ("application/vnd.ms-excel", ".xls"),
"vnd.openxmlformats-officedocument.spreadsheetml.sheet": ("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".xlsx"),
"plain": ("text/plain", ".txt"),
"csv": ("text/csv", ".csv"),
}
adj_type = db_attachment.type or "document"
content_type, ext = content_type_map.get(adj_type, ("application/octet-stream", "bin"))
filename = f"attachment_{attachment_id}.{ext}"
return StreamingResponse(
buffer,
media_type=content_type,
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
+64
View File
@@ -0,0 +1,64 @@
"""
audit.py
Contiene la lógica de los endpoints utilizados para crear y consultar datos de auditoria.
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import desc
from database import get_db
from typing import List, Optional
from datetime import datetime
import models
import schemas
from auth import get_current_user
router = APIRouter()
@router.get("/audit/", response_model=List[schemas.AuditLogResponse], tags=["Audit"])
def get_audit_logs(
entity_type: Optional[str] = None,
entity_id: Optional[str] = None,
action: Optional[str] = None,
user_id: Optional[int] = None,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None,
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: str = Depends(get_current_user),
):
"""
Consulta el log de auditoría con filtros opcionales.
Resultados ordenados del más reciente al más antiguo.
"""
query = db.query(models.AuditLog).order_by(desc(models.AuditLog.timestamp))
if entity_type:
query = query.filter(models.AuditLog.entity_type == entity_type)
if entity_id:
query = query.filter(models.AuditLog.entity_id == entity_id)
if action:
query = query.filter(models.AuditLog.action == action)
if user_id:
query = query.filter(models.AuditLog.user_id == user_id)
if date_from:
query = query.filter(models.AuditLog.timestamp >= date_from)
if date_to:
query = query.filter(models.AuditLog.timestamp <= date_to)
return query.offset(skip).limit(limit).all()
@router.get("/audit/{log_id}", response_model=schemas.AuditLogResponse, tags=["Audit"])
def get_audit_log(
log_id: int,
db: Session = Depends(get_db),
current_user: str = Depends(get_current_user),
):
log = db.query(models.AuditLog).filter(models.AuditLog.id == log_id).first()
if not log:
raise HTTPException(status_code=404, detail="Audit log not found")
return log
+166
View File
@@ -0,0 +1,166 @@
"""
groups.py
Contiene los endpoints de CRUD de Grupos.
"""
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from database import get_db
import models
import schemas
from typing import List
from auth import get_current_user
from audit import log_action
router = APIRouter()
@router.post("/groups/", response_model=schemas.GroupResponse, tags=['Groups'])
def create_group(
group: schemas.GroupCreate,
request: Request,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
existing = db.query(models.Group).filter(
models.Group.id_telegram == group.id_telegram
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Group with Telegram ID {group.id_telegram} already exists"
)
db_group = models.Group(**group.model_dump())
db.add(db_group)
db.flush()
log_action(
db=db, entity_type='group', entity_id=db_group.id_telegram,
action='create', user_id=current_user,
after=db_group, ip_address=request.client.host
)
db.commit()
db.refresh(db_group)
return db_group
@router.get("/groups/", response_model=List[schemas.GroupResponse], tags=['Groups'])
def read_groups(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
return db.query(models.Group).filter(
models.Group.type != 'P'
).offset(skip).limit(limit).all()
@router.get("/groups/{group_id}", response_model=schemas.GroupResponse, tags=['Groups'])
def read_group(
group_id: int,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
db_group = db.query(models.Group).filter(
models.Group.id_telegram == group_id
).first()
if not db_group:
raise HTTPException(status_code=404, detail="Group not found")
return db_group
@router.get("/groups/telegram/{id_telegram}", response_model=schemas.GroupResponse, tags=['Groups'])
def read_group_telegram(id_telegram: int, db: Session = Depends(get_db)):
db_group = db.query(models.Group).filter(
models.Group.id_telegram == id_telegram
).first()
if not db_group:
raise HTTPException(status_code=404, detail="Group not found")
return db_group
@router.patch("/groups/{group_id}/update-position", response_model=schemas.GroupResponse, tags=['Groups'])
def update_group_message_position(
group_id: int,
update_data: schemas.GroupUpdatePosition,
request: Request,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
db_group = db.query(models.Group).filter(
models.Group.id_telegram == group_id
).first()
if not db_group:
raise HTTPException(status_code=404, detail=f"Group with ID {group_id} not found")
before_pos = db_group.message_position
db_group.message_position = update_data.message_position
log_action(
db=db, entity_type='group', entity_id=group_id,
action='update', user_id=current_user,
before={'message_position': before_pos},
after={'message_position': update_data.message_position},
ip_address=request.client.host
)
db.commit()
db.refresh(db_group)
return db_group
@router.patch("/groups/{group_id}", response_model=schemas.GroupResponse, tags=['Groups'])
def update_group(
group_id: int,
update_data: schemas.GroupUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
db_group = db.query(models.Group).filter(
models.Group.id_telegram == group_id
).first()
if not db_group:
raise HTTPException(status_code=404, detail=f"Group with ID {group_id} not found")
before_snapshot = {
'name': db_group.name,
'description': db_group.description,
'type': db_group.type,
}
db_group.name = update_data.name
db_group.description = update_data.description
db_group.type = update_data.type
log_action(
db=db, entity_type='group', entity_id=group_id,
action='update', user_id=current_user,
before=before_snapshot, after=update_data.model_dump(),
ip_address=request.client.host
)
db.commit()
db.refresh(db_group)
return db_group
@router.delete("/groups/{group_id}", tags=['Groups'])
def delete_group(
group_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
db_group = db.query(models.Group).filter(
models.Group.id_telegram == group_id
).first()
if not db_group:
raise HTTPException(status_code=404, detail="Group not found")
log_action(
db=db, entity_type='group', entity_id=group_id,
action='delete', user_id=current_user,
before=db_group, ip_address=request.client.host
)
db.delete(db_group)
db.commit()
return {"message": "Group deleted successfully"}
+264
View File
@@ -0,0 +1,264 @@
"""
Mannage.py
Contiene los endpoints utilziados para adminitración o manejo de grupos o sesiones de telegram.
"""
from time import time
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from integrations.chats import TelegramChatSingleton
from dotenv import load_dotenv
import os
from typing import Dict, Optional
import requests
import asyncio
from integrations.api_implementations import post_api_sync, create_telegram_group
from telethon import TelegramClient
from telethon.sessions import StringSession
from telethon.errors import SessionPasswordNeededError
import httpx
import uuid
from auth import get_current_user
#Almacenes temporales
login_flows = {} # flow_id -> {"client": TelegramClient, "phone": str}
user_sessions = {} # token -> session_string
class VerifyCodeRequest(BaseModel):
flow_id: str
code: str
password: Optional[str] = None
class VerifyCodeResponse(BaseModel):
token: str
message: str
router = APIRouter()
load_dotenv()
API_BASE_URL = os.getenv('API_URL')
HEADERS = {"Content-Type": "application/json"}
TIMEOUT = 5.0
cache_status = {"connected": None, "last_check": 0}
TTL = 10 # segundos
router = APIRouter()
load_dotenv()
@router.post("/manage/", tags=['Manage'])
def add_pending_chat(channel: int, current_user: str = Depends(get_current_user)):
"""
Agrega un canal/grupo viendo si existe en telegram anteriormente.
Primero valida que el ID sea accesible en Telegram,
luego lo guarda con los datos reales (nombre, tipo).
"""
# 1. Verificar que el cliente de Telegram está disponible
try:
scraper = TelegramChatSingleton.get_scraper()
except Exception as e:
raise HTTPException(
status_code=503,
detail=f"El cliente de Telegram no está disponible: {str(e)}"
)
# 2. Validar el canal contra Telegram
try:
validation = scraper.validate_chat(channel)
except RuntimeError as e:
raise HTTPException(status_code=503, detail=str(e))
except Exception as e:
raise HTTPException(
status_code=503,
detail=f"Error al validar en Telegram: {str(e)}"
)
if not validation["valid"]:
raise HTTPException(
status_code=400,
detail=f"Canal no válido o inaccesible: {validation.get('error', 'desconocido')}"
)
info = validation["info"]
# 3. Guardar con datos reales de Telegram — sin pasar por estado pendiente
from integrations.api_implementations import refresh_telegram_group
tipo_interno = info["type"]
nombre = info["username"] if info["username"] != str(info["id_telegram"]) else info["title"] or str(info["id_telegram"])
# Intentar crear primero (caso nuevo)
new_group = {
"id_telegram": info["id_telegram"],
"name": nombre,
"description": info["description"] or info["title"] or "",
"type": tipo_interno,
"message_position": 0,
}
created_group = create_telegram_group(new_group)
if created_group:
return {
"status": "success",
"group": created_group,
"message": "Grupo validado y agregado correctamente",
"details": {
"title": info["title"],
"type": info["type"],
"id_telegram": info["id_telegram"],
}
}
# Si ya existía, actualizar sus datos con los de Telegram
update_data = {
"name": nombre,
"description": info["description"] or info["title"] or "",
"type": tipo_interno,
}
updated_group = refresh_telegram_group(info["id_telegram"], update_data)
if updated_group:
return {
"status": "updated",
"group": updated_group,
"message": "El grupo ya existía y fue actualizado con los datos de Telegram",
"details": {
"title": info["title"],
"type": info["type"],
"id_telegram": info["id_telegram"],
}
}
return {
"status": "exists",
"message": f"El grupo con ID {info['id_telegram']} ya existe en el sistema"
}
@router.get("/manage/connection-status", tags=['Manage'])
async def telegram_status():
ahora = time()
if ahora - cache_status["last_check"] < TTL and cache_status["connected"] is not None:
return {"connected": cache_status["connected"], "cached": True}
# Realizar la verificación real
try:
response = requests.get("https://api.telegram.org", timeout=2)
cache_status["connected"] = response.status_code == 200 or 302
except:
cache_status["connected"] = False
cache_status["last_check"] = ahora
return {"connected": cache_status["connected"], "cached": False}
@router.get("/manage/init-session", tags=['Manage'])
async def init_session(current_user: str = Depends(get_current_user)):
flow_id = str(uuid.uuid4())
session_dir = os.path.join(os.getcwd(), "telegram_sessions")
os.makedirs(session_dir, exist_ok=True)
# --- Limpiar sesiones anteriores de forma segura ---
active_session_files = {
f"{data['client'].session.filename}"
for data in login_flows.values()
if "client" in data
}
for filename in os.listdir(session_dir):
if not filename.endswith(".session"):
continue
filepath = os.path.join(session_dir, filename)
# Verificar que es un archivo real y no está en uso
if not os.path.isfile(filepath):
continue
if filepath in active_session_files:
print(f"[INFO] Sesión en uso, se omite: {filename}")
continue
try:
os.remove(filepath)
print(f"[INFO] Sesión eliminada: {filename}")
except Exception as e:
print(f"[WARN] No se pudo eliminar {filename}: {e}")
# Usar el flow_id como nombre de sesión para evitar conflictos entre flujos
session_file = os.path.join(session_dir, f"session_{flow_id}")
client = TelegramClient(
session_file,
int(os.getenv('TELEGRAM_API_ID')), # debe ser int
os.getenv('TELEGRAM_API_HASH')
)
# ✅ await directamente, nunca asyncio.run() dentro de async def
await client.connect()
if not await client.is_user_authorized():
try:
await client.send_code_request(phone=os.getenv('TELEGRAM_TELEPHONE'))
except Exception as e:
await client.disconnect()
raise HTTPException(status_code=400, detail=f"Error enviando código: {str(e)}")
login_flows[flow_id] = {
"client": client,
"phone": os.getenv('TELEGRAM_TELEPHONE')
}
return {
"flow_id": flow_id,
"message": "Código enviado. Usa /verify-code para completar la autenticación."
}
@router.post("/manage/verify-code", tags=['Manage'])
async def verify_code(request: VerifyCodeRequest, current_user: str = Depends(get_current_user)):
flow = login_flows.get(request.flow_id)
if not flow:
raise HTTPException(status_code=404, detail="Flow no encontrado o expirado")
client: TelegramClient = flow["client"]
phone = flow["phone"]
# Reconectar si el cliente se desconectó entre requests
if not client.is_connected():
await client.connect()
try:
await client.sign_in(phone=phone, code=request.code)
except SessionPasswordNeededError:
if not request.password:
raise HTTPException(
status_code=428,
detail="Se requiere contraseña de dos factores"
)
try:
await client.sign_in(password=request.password)
except Exception as e:
await client.disconnect()
del login_flows[request.flow_id]
raise HTTPException(status_code=400, detail=f"Error en contraseña 2FA: {str(e)}")
except Exception as e:
await client.disconnect()
del login_flows[request.flow_id]
raise HTTPException(status_code=400, detail=f"Error verificando código: {str(e)}")
# Éxito: guardar sesión y limpiar
session_string = client.session.save()
token = str(uuid.uuid4())
user_sessions[token] = session_string
await asyncio.sleep(1)
await client.disconnect()
del login_flows[request.flow_id]
# Limpiar la instancia singleton para que tome la nueva sesión
TelegramChatSingleton.cleanup()
#Inicializa scraper.
TelegramChatSingleton.get_scraper()
return {"token": token, "message": "Autenticación exitosa"}
+191
View File
@@ -0,0 +1,191 @@
"""
Messages.py
Contiene los endpoints de CRUD de mensajes.
"""
import re
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from database import get_db
import models
import schemas
from typing import List, Optional
from datetime import datetime
from auth import get_current_user
from audit import log_action
router = APIRouter()
@router.post("/messages/", response_model=schemas.MessageResponse, tags=['Messages'])
def create_message(
message: schemas.MessageCreate,
request: Request,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
existing = db.query(models.Message).filter(
models.Message.id_mess_g == message.id_mess_g,
models.Message.group_id == message.group_id
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Message in Group ID {message.group_id} with Message ID {message.id_mess_g} already exists"
)
db_message = models.Message(**message.model_dump())
db.add(db_message)
db.flush()
log_action(
db=db, entity_type='message',
entity_id=f"{db_message.id_mess_g}_{db_message.group_id}",
action='create', user_id=current_user,
after={'id_mess_g': db_message.id_mess_g, 'group_id': db_message.group_id,
'sender_id': db_message.sender_id, 'date': str(db_message.date)},
ip_address=request.client.host
)
rules = db.query(models.Rule).filter(models.Rule.is_active == True).all()
alerts = []
for rule in rules:
try:
if re.search(rule.regex, db_message.content, re.IGNORECASE):
new_alert = models.Alert(
message_id=db_message.id_mess_g,
group_id=db_message.group_id,
rule_id=rule.id,
status="open",
notes=f"Detected pattern: {rule.regex}",
created_at=datetime.utcnow()
)
db.add(new_alert)
alerts.append(new_alert)
except re.error:
print(f"[WARN] Regex inválida en regla {rule.id}: {rule.regex}")
continue
db.commit()
db.refresh(db_message)
return db_message
@router.get("/messages/", response_model=List[schemas.MessageResponse], tags=['Messages'])
def read_messages(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
return db.query(models.Message).offset(skip).limit(limit).all()
@router.get("/messages/search/", response_model=List[schemas.MessageResponse], tags=['Messages'])
def search_messages(
q: str = "",
group_id: int = None,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None,
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
query = db.query(models.Message)
if q:
query = query.filter(models.Message.content.ilike(f"%{q}%"))
if group_id:
query = query.filter(models.Message.group_id == group_id)
if date_from:
query = query.filter(models.Message.date >= date_from)
if date_to:
query = query.filter(models.Message.date <= date_to)
return query.offset(skip).limit(limit).all()
@router.get("/groups/{group_id}/messages/", response_model=List[schemas.MessageResponse], tags=['Messages'])
def read_messages_by_group(
group_id: int,
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
db_group = db.query(models.Group).filter(
models.Group.id_telegram == group_id
).first()
if not db_group:
raise HTTPException(status_code=404, detail="Group not found")
return db.query(models.Message).filter(
models.Message.group_id == group_id
).offset(skip).limit(limit).all()
@router.get("/senders/{sender_id}/messages/", response_model=List[schemas.MessageResponse], tags=['Messages'])
def read_messages_by_sender(
sender_id: int,
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
db_sender = db.query(models.Sender).filter(
models.Sender.id_telegram == sender_id
).first()
if not db_sender:
raise HTTPException(status_code=404, detail="Sender not found")
return db.query(models.Message).filter(
models.Message.sender_id == sender_id
).offset(skip).limit(limit).all()
@router.get("/groups/{group_id}/messages/{message_id}", response_model=schemas.MessageResponse, tags=['Messages'])
def read_message(
group_id: int,
message_id: int,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
db_message = db.query(models.Message).filter(
models.Message.id_mess_g == message_id,
models.Message.group_id == group_id
).first()
if not db_message:
raise HTTPException(
status_code=404,
detail=f"Message with ID {message_id} in group {group_id} not found"
)
return db_message
@router.delete("/groups/{group_id}/messages/{message_id}", tags=['Messages'])
def delete_message(
group_id: int,
message_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
db_message = db.query(models.Message).filter(
models.Message.id_mess_g == message_id,
models.Message.group_id == group_id
).first()
if not db_message:
raise HTTPException(
status_code=404,
detail=f"Message with ID {message_id} in group {group_id} not found"
)
log_action(
db=db, entity_type='message',
entity_id=f"{message_id}_{group_id}",
action='delete', user_id=current_user,
before={'id_mess_g': message_id, 'group_id': group_id},
ip_address=request.client.host
)
db.delete(db_message)
db.commit()
return {"message": "Message deleted successfully"}
+90
View File
@@ -0,0 +1,90 @@
"""
notes.py
Contiene los endpoints de CRUD de notas.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from database import get_db
import models
import schemas
from typing import List
from datetime import datetime
router = APIRouter()
@router.post("/notes/", response_model=schemas.NoteResponse, tags=['Notes'])
def create_note(note: schemas.NoteCreate, db: Session = Depends(get_db)):
# verificar que la alerta existe
db_alert = db.query(models.Alert).filter(models.Alert.id == note.alert_id).first()
if not db_alert:
raise HTTPException(status_code=404, detail="Alert not found")
db_note = models.Note(
alert_id=note.alert_id,
user_id=note.user_id,
content=note.content,
creation_date=datetime.utcnow()
)
db.add(db_note)
db.commit()
db.refresh(db_note)
return db_note
@router.get("/notes/", response_model=List[schemas.NoteResponse], tags=['Notes'])
def read_notes(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
notes = db.query(models.Note).offset(skip).limit(limit).all()
return notes
@router.get("/notes/{note_id}", response_model=schemas.NoteResponse, tags=['Notes'])
def read_note(note_id: int, db: Session = Depends(get_db)):
db_note = db.query(models.Note).filter(models.Note.id == note_id).first()
if not db_note:
raise HTTPException(status_code=404, detail="Note not found")
return db_note
@router.put("/notes/{note_id}", response_model=schemas.NoteResponse, tags=['Notes'])
def update_note(note_id: int, note: schemas.NoteCreate, db: Session = Depends(get_db)):
db_note = db.query(models.Note).filter(models.Note.id == note_id).first()
if not db_note:
raise HTTPException(status_code=404, detail="Note not found")
# validar cambios de alerta
if note.alert_id != db_note.alert_id:
db_alert = db.query(models.Alert).filter(models.Alert.id == note.alert_id).first()
if not db_alert:
raise HTTPException(status_code=404, detail="New alert not found")
# validar cambio de usuario
if note.user_id != db_note.user_id:
existing = db.query(models.Note).filter(models.Note.user_id == note.user_id).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Note for user {note.user_id} already exists"
)
for field, value in note.model_dump().items():
setattr(db_note, field, value)
db.commit()
db.refresh(db_note)
return db_note
@router.delete("/notes/{note_id}", tags=['Notes'])
def delete_note(note_id: int, db: Session = Depends(get_db)):
db_note = db.query(models.Note).filter(models.Note.id == note_id).first()
if not db_note:
raise HTTPException(status_code=404, detail="Note not found")
db.delete(db_note)
db.commit()
return {"message": "Note deleted successfully"}
# Special endpoints
@router.get("/alerts/{alert_id}/notes", response_model=List[schemas.NoteResponse], tags=['Notes'])
def get_alert_notes(alert_id: int, db: Session = Depends(get_db)):
db_alert = db.query(models.Alert).filter(models.Alert.id == alert_id).first()
if not db_alert:
raise HTTPException(status_code=404, detail="Alert not found")
return db.query(models.Note).filter(models.Note.alert_id == alert_id).all()
@router.get("/users/{user_id}/notes", response_model=List[schemas.NoteResponse], tags=['Notes'])
def get_user_notes(user_id: int, db: Session = Depends(get_db)):
notes = db.query(models.Note).filter(models.Note.user_id == user_id).all()
return notes
+162
View File
@@ -0,0 +1,162 @@
"""
Rules.py
Contiene los endpoints de CRUD de reglas.
"""
import re
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from database import get_db
import models
import schemas
from typing import List
from datetime import datetime
from auth import get_current_user
from audit import log_action
router = APIRouter()
@router.post("/rules/", response_model=schemas.RuleResponse, tags=['Rules'])
def create_rule(
rule: schemas.RuleCreate,
request: Request,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
# Validar que la regex compile antes de guardar
try:
re.compile(rule.regex)
except re.error as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Expresión regular inválida: {e}"
)
db_rule = models.Rule(**rule.model_dump(exclude={"apply_to_history"}))
db.add(db_rule)
db.flush() # para tener db_rule.id antes del commit
# Auditoría
log_action(
db=db, entity_type='rule', entity_id=db_rule.id,
action='create', user_id=current_user,
after=db_rule, ip_address=request.client.host
)
if rule.apply_to_history:
# Traer mensajes en batches para no cargar todo en memoria
batch_size = 500
offset = 0
while True:
messages = db.query(models.Message).offset(offset).limit(batch_size).all()
if not messages:
break
for message in messages:
try:
if re.search(rule.regex, message.content, re.IGNORECASE):
new_alert = models.Alert(
message_id=message.id_mess_g,
group_id=message.group_id,
rule_id=db_rule.id,
status="open",
notes=f"Detected pattern: {rule.regex}",
created_at=datetime.utcnow()
)
db.add(new_alert)
except re.error:
break
offset += batch_size
db.commit()
db.refresh(db_rule)
return db_rule
@router.get("/rules/", response_model=List[schemas.RuleResponse], tags=['Rules'])
def read_rules(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
return db.query(models.Rule).offset(skip).limit(limit).all()
@router.get("/rules/search/", response_model=List[schemas.RuleResponse], tags=['Rules'])
def search_rules(
q: str,
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
return db.query(models.Rule).filter(
models.Rule.description.ilike(f"%{q}%")
).offset(skip).limit(limit).all()
@router.get("/rules/{rule_id}", response_model=schemas.RuleResponse, tags=['Rules'])
def read_rule(
rule_id: int,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
db_rule = db.query(models.Rule).filter(models.Rule.id == rule_id).first()
if not db_rule:
raise HTTPException(status_code=404, detail="Rule not found")
return db_rule
@router.put("/rules/{rule_id}", response_model=schemas.RuleResponse, tags=['Rules'])
def update_rule(
rule_id: int,
rule: schemas.RuleCreate,
request: Request,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
db_rule = db.query(models.Rule).filter(models.Rule.id == rule_id).first()
if not db_rule:
raise HTTPException(status_code=404, detail="Rule not found")
# Capturar estado anterior antes de modificar
before_snapshot = {
'id': db_rule.id,
'description': db_rule.description,
'regex': db_rule.regex,
'severity': db_rule.severity,
'is_active': db_rule.is_active,
}
for field, value in rule.model_dump().items():
setattr(db_rule, field, value)
log_action(
db=db, entity_type='rule', entity_id=rule_id,
action='update', user_id=current_user,
before=before_snapshot, after=db_rule,
ip_address=request.client.host
)
db.commit()
db.refresh(db_rule)
return db_rule
@router.delete("/rules/{rule_id}", tags=['Rules'])
def delete_rule(
rule_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
db_rule = db.query(models.Rule).filter(models.Rule.id == rule_id).first()
if not db_rule:
raise HTTPException(status_code=404, detail="Rule not found")
log_action(
db=db, entity_type='rule', entity_id=rule_id,
action='delete', user_id=current_user,
before=db_rule, ip_address=request.client.host
)
db.delete(db_rule)
db.commit()
return {"message": "Rule deleted successfully"}
+127
View File
@@ -0,0 +1,127 @@
"""
Senders.py
Contiene los endpoints de CRUD de remitentes.
"""
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from database import get_db
import models
import schemas
from typing import List
from auth import get_current_user
from audit import log_action
router = APIRouter()
@router.post("/senders/", response_model=schemas.SenderResponse, tags=['Senders'])
def create_sender(
sender: schemas.SenderCreate,
request: Request,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
existing = db.query(models.Sender).filter(
models.Sender.id_telegram == sender.id_telegram
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Sender with Telegram ID {sender.id_telegram} already exists"
)
db_sender = models.Sender(**sender.model_dump())
db.add(db_sender)
db.flush()
log_action(
db=db, entity_type='sender', entity_id=db_sender.id_telegram,
action='create', user_id=current_user,
after=db_sender, ip_address=request.client.host
)
db.commit()
db.refresh(db_sender)
return db_sender
@router.get("/senders/", response_model=List[schemas.SenderResponse], tags=['Senders'])
def read_senders(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
return db.query(models.Sender).offset(skip).limit(limit).all()
@router.get("/senders/{sender_id}", response_model=schemas.SenderResponse, tags=['Senders'])
def read_sender(
sender_id: int,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
db_sender = db.query(models.Sender).filter(
models.Sender.id_telegram == sender_id
).first()
if not db_sender:
raise HTTPException(status_code=404, detail="Sender not found")
return db_sender
@router.put("/senders/{sender_id}", response_model=schemas.SenderResponse, tags=['Senders'])
def update_sender(
sender_id: int,
sender: schemas.SenderCreate,
request: Request,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
db_sender = db.query(models.Sender).filter(
models.Sender.id_telegram == sender_id
).first()
if not db_sender:
raise HTTPException(status_code=404, detail="Sender not found")
before_snapshot = {
'id_telegram': db_sender.id_telegram,
'type': db_sender.type,
'username': db_sender.username,
'first_name': db_sender.first_name,
'last_name': db_sender.last_name,
'phone': db_sender.phone,
}
for field, value in sender.model_dump().items():
setattr(db_sender, field, value)
log_action(
db=db, entity_type='sender', entity_id=sender_id,
action='update', user_id=current_user,
before=before_snapshot, after=db_sender,
ip_address=request.client.host
)
db.commit()
db.refresh(db_sender)
return db_sender
@router.delete("/senders/{sender_id}", tags=['Senders'])
def delete_sender(
sender_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: int = Depends(get_current_user)
):
db_sender = db.query(models.Sender).filter(
models.Sender.id_telegram == sender_id
).first()
if not db_sender:
raise HTTPException(status_code=404, detail="Sender not found")
log_action(
db=db, entity_type='sender', entity_id=sender_id,
action='delete', user_id=current_user,
before=db_sender, ip_address=request.client.host
)
db.delete(db_sender)
db.commit()
return {"message": "Sender deleted successfully"}
+320
View File
@@ -0,0 +1,320 @@
"""
Stats.py
Contiene la lógica de los endpoints utilizados para crear y consultar datos estadísticos.
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import func, cast, Integer, extract
from database import get_db
from typing import Optional
from datetime import datetime, timedelta
import models
from auth import get_current_user
router = APIRouter()
@router.get("/stats/", tags=["Stats"])
def get_stats(
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None,
db: Session = Depends(get_db),
current_user: str = Depends(get_current_user),
):
"""
Devuelve todas las estadísticas agregadas en una sola llamada.
Si no se pasan fechas, usa los últimos 30 días.
"""
if date_to is None:
date_to = datetime.utcnow()
if date_from is None:
date_from = date_to - timedelta(days=30)
# ------------------------------------------------------------------
# 1. Resumen ejecutivo
# ------------------------------------------------------------------
total_alertas = db.query(models.Alert).filter(
models.Alert.created_at >= date_from,
models.Alert.created_at <= date_to
).count()
alertas_abiertas = db.query(models.Alert).filter(
models.Alert.created_at >= date_from,
models.Alert.created_at <= date_to,
models.Alert.status == "open"
).count()
alertas_en_progreso = db.query(models.Alert).filter(
models.Alert.created_at >= date_from,
models.Alert.created_at <= date_to,
models.Alert.status == "in_progress"
).count()
alertas_cerradas = total_alertas - alertas_abiertas - alertas_en_progreso
total_mensajes = db.query(models.Message).filter(
models.Message.date >= date_from,
models.Message.date <= date_to
).count()
grupos_activos = db.query(models.Group).filter(
models.Group.type != "P"
).count()
reglas_activas = db.query(models.Rule).filter(
models.Rule.is_active == True
).count()
# ------------------------------------------------------------------
# 2. Alertas por día (para gráfico de línea)
# ------------------------------------------------------------------
alertas_por_dia_raw = (
db.query(
func.date(models.Alert.created_at).label("dia"),
models.Alert.status,
func.count(models.Alert.id).label("total")
)
.filter(
models.Alert.created_at >= date_from,
models.Alert.created_at <= date_to
)
.group_by(
func.date(models.Alert.created_at),
models.Alert.status
)
.order_by(func.date(models.Alert.created_at))
.all()
)
alertas_por_dia = {}
for row in alertas_por_dia_raw:
dia = str(row.dia)
if dia not in alertas_por_dia:
alertas_por_dia[dia] = {"abiertas": 0, "cerradas": 0}
if row.status == "open":
alertas_por_dia[dia]["abiertas"] = row.total
else:
alertas_por_dia[dia]["cerradas"] = row.total
# ------------------------------------------------------------------
# 3. Distribución por severidad
# ------------------------------------------------------------------
sev_raw = (
db.query(
models.Rule.severity,
func.count(models.Alert.id).label("total")
)
.join(models.Alert, models.Alert.rule_id == models.Rule.id)
.filter(
models.Alert.created_at >= date_from,
models.Alert.created_at <= date_to
)
.group_by(models.Rule.severity)
.all()
)
distribucion_severidad = {row.severity: row.total for row in sev_raw}
# ------------------------------------------------------------------
# 4. Alertas por grupo
# ------------------------------------------------------------------
alertas_por_grupo_raw = (
db.query(
models.Alert.group_id,
func.count(models.Alert.id).label("total")
)
.filter(
models.Alert.created_at >= date_from,
models.Alert.created_at <= date_to
)
.group_by(models.Alert.group_id)
.order_by(func.count(models.Alert.id).desc())
.limit(10)
.all()
)
# Resolver nombres de grupos
group_ids = [r.group_id for r in alertas_por_grupo_raw]
grupos_map = {
g.id_telegram: g.name
for g in db.query(models.Group).filter(
models.Group.id_telegram.in_(group_ids)
).all()
}
alertas_por_grupo = [
{
"grupo": grupos_map.get(r.group_id, str(r.group_id)),
"total": r.total
}
for r in alertas_por_grupo_raw
]
# ------------------------------------------------------------------
# 5. Reglas más disparadas (top 10)
# ------------------------------------------------------------------
reglas_top_raw = (
db.query(
models.Rule.id,
models.Rule.description,
models.Rule.severity,
func.count(models.Alert.id).label("total")
)
.join(models.Alert, models.Alert.rule_id == models.Rule.id)
.filter(
models.Alert.created_at >= date_from,
models.Alert.created_at <= date_to
)
.group_by(models.Rule.id, models.Rule.description, models.Rule.severity)
.order_by(func.count(models.Alert.id).desc())
.limit(10)
.all()
)
reglas_top = [
{
"id": row.id,
"description": row.description,
"severity": row.severity,
"total": row.total
}
for row in reglas_top_raw
]
# ------------------------------------------------------------------
# 6. Volumen de mensajes por día
# ------------------------------------------------------------------
mensajes_por_dia_raw = (
db.query(
func.date(models.Message.date).label("dia"),
func.count(models.Message.id_mess_g).label("total")
)
.filter(
models.Message.date >= date_from,
models.Message.date <= date_to
)
.group_by(func.date(models.Message.date))
.order_by(func.date(models.Message.date))
.all()
)
mensajes_por_dia = {str(row.dia): row.total for row in mensajes_por_dia_raw}
# ------------------------------------------------------------------
# 7. Heatmap: mensajes por hora × día de semana
# ------------------------------------------------------------------
# MariaDB usa DAYOFWEEK() (1=Dom..7=Sab) y HOUR() en lugar de extract("dow"/"hour")
from sqlalchemy import text
heatmap_raw = (
db.query(
func.dayofweek(models.Message.date).label("dia_semana"),
func.hour(models.Message.date).label("hora"),
func.count(models.Message.id_mess_g).label("total")
)
.filter(
models.Message.date >= date_from,
models.Message.date <= date_to
)
.group_by(
func.dayofweek(models.Message.date),
func.hour(models.Message.date)
)
.all()
)
# MariaDB DAYOFWEEK: 1=Dom, 2=Lun, ..., 7=Sáb
DIAS = {1: "Dom", 2: "Lun", 3: "Mar", 4: "Mié", 5: "Jue", 6: "Vie", 7: "Sáb"}
heatmap = [
{
"dia": DIAS.get(int(row.dia_semana), str(row.dia_semana)),
"hora": int(row.hora),
"total": row.total
}
for row in heatmap_raw
]
# ------------------------------------------------------------------
# 8. Top senders por actividad
# ------------------------------------------------------------------
top_senders_raw = (
db.query(
models.Message.sender_id,
func.count(models.Message.id_mess_g).label("mensajes")
)
.filter(
models.Message.date >= date_from,
models.Message.date <= date_to
)
.group_by(models.Message.sender_id)
.order_by(func.count(models.Message.id_mess_g).desc())
.limit(10)
.all()
)
sender_ids = [r.sender_id for r in top_senders_raw]
senders_map = {
s.id_telegram: s
for s in db.query(models.Sender).filter(
models.Sender.id_telegram.in_(sender_ids)
).all()
}
# Contar alertas por sender
alertas_sender_raw = (
db.query(
models.Message.sender_id,
func.count(models.Alert.id).label("alertas")
)
.join(models.Alert, (
models.Alert.message_id == models.Message.id_mess_g) & (
models.Alert.group_id == models.Message.group_id)
)
.filter(
models.Message.date >= date_from,
models.Message.date <= date_to,
models.Message.sender_id.in_(sender_ids)
)
.group_by(models.Message.sender_id)
.all()
)
alertas_por_sender = {r.sender_id: r.alertas for r in alertas_sender_raw}
top_senders = []
for row in top_senders_raw:
s = senders_map.get(row.sender_id)
nombre = ""
if s:
nombre = f"{s.first_name or ''} {s.last_name or ''}".strip() or s.username or str(row.sender_id)
else:
nombre = str(row.sender_id)
top_senders.append({
"id_telegram": row.sender_id,
"nombre": nombre,
"username": s.username if s else None,
"mensajes": row.mensajes,
"alertas": alertas_por_sender.get(row.sender_id, 0),
})
# ------------------------------------------------------------------
# Respuesta final
# ------------------------------------------------------------------
return {
"periodo": {
"desde": date_from.isoformat(),
"hasta": date_to.isoformat(),
},
"resumen": {
"total_alertas": total_alertas,
"alertas_abiertas": alertas_abiertas,
"alertas_cerradas": alertas_cerradas,
"total_mensajes": total_mensajes,
"grupos_activos": grupos_activos,
"reglas_activas": reglas_activas,
},
"alertas_por_dia": alertas_por_dia,
"distribucion_severidad": distribucion_severidad,
"alertas_por_grupo": alertas_por_grupo,
"reglas_top": reglas_top,
"mensajes_por_dia": mensajes_por_dia,
"heatmap": heatmap,
"top_senders": top_senders,
}
+219
View File
@@ -0,0 +1,219 @@
from pydantic import BaseModel, field_validator
from typing import Optional, List
from datetime import datetime
# Group Schemas
class GroupBase(BaseModel):
id_telegram: int
name: str
description: Optional[str] = None
type: str
message_position: int
class GroupCreate(GroupBase):
pass
class GroupResponse(GroupBase):
# Eliminar la lista de messages para evitar recursión
class Config:
from_attributes = True
class GroupWithMessages(GroupBase):
# Solo incluir información básica de los mensajes, no completa
messages: List["MessageSimple"] = []
class Config:
from_attributes = True
class GroupUpdatePosition(BaseModel):
message_position: int
class GroupUpdate(BaseModel):
name: str
description: Optional[str] = None
type: str
# Sender Schemas
class SenderBase(BaseModel):
id_telegram: int
type: str
username: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
phone: Optional[str] = None
class SenderCreate(SenderBase):
pass
class SenderResponse(SenderBase):
# Eliminar la lista de messages para evitar recursión
class Config:
from_attributes = True
class SenderWithMessages(SenderBase):
# Solo incluir información básica de los mensajes
messages: List["MessageSimple"] = []
class Config:
from_attributes = True
# Message Schemas - Versiones simples para evitar recursión
class MessageSimple(BaseModel):
id_mess_g: int
group_id: int
content: str
date: datetime
class Config:
from_attributes = True
class MessageBase(BaseModel):
id_mess_g: int
content: str
date: datetime
sender_id: int
group_id: int
class MessageCreate(MessageBase):
pass
class MessageResponse(MessageBase):
sender: Optional[SenderResponse] = None
group: Optional[GroupResponse] = None
attachments: List["AttachmentResponse"] = []
class Config:
from_attributes = True
class MessageWithRelations(MessageBase):
# Versión con relaciones completas (usar con cuidado)
sender: Optional[SenderResponse] = None
group: Optional[GroupResponse] = None
attachments: List["AttachmentResponse"] = []
alerts: List["AlertResponse"] = []
class Config:
from_attributes = True
class AttachmentBase(BaseModel):
type: str
description: Optional[str] = None
isDownloaded: bool
class AttachmentCreate(AttachmentBase):
message_id: int
group_id: int # necesario para la FK compuesta
class AttachmentResponse(AttachmentBase):
id: int
message_id: int
group_id: int
class Config:
from_attributes = True
# Rule Schemas
class RuleBase(BaseModel):
description: str
regex: str
severity: str = "media"
is_active: bool = True
@field_validator("description")
@classmethod
def description_not_empty(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError("La descripción no puede estar vacía")
return v.strip()
@field_validator("regex")
@classmethod
def regex_not_empty(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError("La regex no puede estar vacía")
import re
try:
re.compile(v)
except re.error as e:
raise ValueError(f"La regex no es válida: {e}")
return v.strip()
@field_validator("severity")
@classmethod
def severity_valid(cls, v: str) -> str:
allowed = {"baja", "media", "alta"}
if v.lower() not in allowed:
raise ValueError(f"Severidad debe ser una de: {', '.join(allowed)}")
return v.lower()
class RuleCreate(RuleBase):
apply_to_history: bool = False # nuevo campo
pass
class RuleResponse(RuleBase):
id: int
class Config:
from_attributes = True
# Alert Schemas
class AlertBase(BaseModel):
message_id: int
group_id: int
rule_id: int
status: str = "open"
notes: Optional[str] = None
class AlertCreate(AlertBase):
pass
class AlertResponse(AlertBase):
id: int
created_at: datetime
message: Optional[MessageSimple] = None
rule: Optional[RuleResponse] = None
notes_list: List["NoteResponse"] = []
class Config:
from_attributes = True
# Note Schemas
class NoteBase(BaseModel):
alert_id: int
user_id: int
content: str
creation_date: datetime
class NoteCreate(BaseModel):
alert_id: int
user_id: int
content: str
class NoteResponse(NoteBase):
id: int
class Config:
from_attributes = True
class AuditLogResponse(BaseModel):
id: int
entity_type: str
entity_id: str
action: str
user_id: Optional[int] = None
before_value: Optional[str] = None
after_value: Optional[str] = None
timestamp: datetime
ip_address: Optional[str] = None
class Config:
from_attributes = True
# Resolver referencias circulares
MessageSimple.model_rebuild()
MessageResponse.model_rebuild()
MessageWithRelations.model_rebuild()
AttachmentResponse.model_rebuild()
AlertResponse.model_rebuild()
GroupWithMessages.model_rebuild()
SenderWithMessages.model_rebuild()
NoteResponse.model_rebuild()