First commit
This commit is contained in:
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -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
|
||||
+150
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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}"}
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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
@@ -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()
|
||||
Reference in New Issue
Block a user