generado bot de telegram con capacidades de kanboard
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
"""Paquete que contiene las definiciones de agentes personalizados."""
|
||||
|
||||
from .agente_kanboard import AgenteBasico
|
||||
from .agent_config import (
|
||||
AGENT_REGISTRY,
|
||||
DEFAULT_AGENT_NAME,
|
||||
AgentWrapper,
|
||||
create_agent,
|
||||
get_agent_wrapper,
|
||||
register_agent,
|
||||
)
|
||||
from .base import AgenteBase, AgentDefinition
|
||||
|
||||
__all__ = [
|
||||
"AgenteBasico",
|
||||
"AgentDefinition",
|
||||
"AgenteBase",
|
||||
"AgentWrapper",
|
||||
"AGENT_REGISTRY",
|
||||
"DEFAULT_AGENT_NAME",
|
||||
"create_agent",
|
||||
"get_agent_wrapper",
|
||||
"register_agent",
|
||||
]
|
||||
@@ -0,0 +1,293 @@
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from time import perf_counter
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from agno.agent import Agent
|
||||
from agno.db.base import SessionType
|
||||
from agno.models.openai import OpenAIChat
|
||||
|
||||
from .agente_kanboard import AgenteBasico
|
||||
from .base import AgentDefinition
|
||||
from mcp_wrapper import MCPConfigError, initialize_mcp_tools, load_mcp_tools
|
||||
from utils.agno_logging import configure_agno_to_use_loki
|
||||
|
||||
ToolFactory = Callable[[Any], Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentWrapper:
|
||||
name: str
|
||||
description: str
|
||||
system_prompt: str
|
||||
model_id: str
|
||||
memory_config: Dict[str, Any] = field(default_factory=dict)
|
||||
mcp_config: Optional[Dict[str, Any]] = None
|
||||
tool_factories: List[ToolFactory] = field(default_factory=list)
|
||||
markdown: bool = True
|
||||
debug_mode: bool = False
|
||||
telemetry: bool = False
|
||||
model_kwargs: Dict[str, Any] = field(default_factory=dict)
|
||||
db: Optional[Any] = None
|
||||
user_id: Optional[str] = None
|
||||
session_id: Optional[str] = None
|
||||
resume_previous_session: bool = True
|
||||
|
||||
async def create(self, logger) -> Tuple[Agent, Dict[str, Any]]:
|
||||
configure_agno_to_use_loki(logger)
|
||||
|
||||
local_tools = [factory(logger) for factory in self.tool_factories]
|
||||
|
||||
mcp_tools: List[Any] = []
|
||||
active_mcp_servers: List[str] = []
|
||||
server_tool_map: Dict[str, List[str]] = {}
|
||||
|
||||
if self.mcp_config:
|
||||
mcp_config = dict(self.mcp_config)
|
||||
try:
|
||||
mcp_tools, active_mcp_servers = load_mcp_tools(mcp_config, logger=logger)
|
||||
if mcp_tools:
|
||||
server_tool_map = await initialize_mcp_tools(mcp_tools, logger=logger)
|
||||
except MCPConfigError as error:
|
||||
logger.error(
|
||||
"🚨 Configuración MCP inválida",
|
||||
add_fields={
|
||||
"error": str(error),
|
||||
"agent_call": {
|
||||
"action": "load_mcp_config",
|
||||
"agent_name": self.name,
|
||||
},
|
||||
"agent_response": {"status": "error", "error": str(error)},
|
||||
},
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"💥 Error inesperado configurando MCP",
|
||||
add_fields={
|
||||
"error": str(error),
|
||||
"agent_call": {
|
||||
"action": "load_mcp_config",
|
||||
"agent_name": self.name,
|
||||
},
|
||||
"agent_response": {"status": "error", "error": str(error)},
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
if self.db:
|
||||
logger.info(
|
||||
"🗄️ Base de datos SQLite configurada para el agente",
|
||||
add_fields={
|
||||
"agent_call": {
|
||||
"action": "configure_memory",
|
||||
"agent_name": self.name,
|
||||
"db_file": getattr(self.db, "db_file", None),
|
||||
},
|
||||
"agent_response": {
|
||||
"status": "db_ready",
|
||||
"resume_previous_session": self.resume_previous_session,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if self.resume_previous_session and not self.session_id and self.user_id:
|
||||
start = perf_counter()
|
||||
try:
|
||||
sessions = self.db.get_sessions(user_id=self.user_id, session_type=SessionType.AGENT)
|
||||
duration_ms = round((perf_counter() - start) * 1000, 3)
|
||||
if sessions:
|
||||
self.session_id = getattr(sessions[0], "session_id", None)
|
||||
logger.info(
|
||||
"📂 Sesión previa recuperada desde memoria persistente",
|
||||
add_fields={
|
||||
"agent_call": {
|
||||
"action": "restore_session",
|
||||
"agent_name": self.name,
|
||||
"user_id": self.user_id,
|
||||
},
|
||||
"agent_response": {
|
||||
"status": "session_found",
|
||||
"session_id": self.session_id,
|
||||
"sessions_encontradas": len(sessions),
|
||||
"duration_ms": duration_ms,
|
||||
},
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"🔎 No se encontró sesión previa para el agente",
|
||||
add_fields={
|
||||
"agent_call": {
|
||||
"action": "restore_session",
|
||||
"agent_name": self.name,
|
||||
"user_id": self.user_id,
|
||||
},
|
||||
"agent_response": {
|
||||
"status": "session_not_found",
|
||||
"duration_ms": duration_ms,
|
||||
},
|
||||
},
|
||||
)
|
||||
except Exception as error:
|
||||
duration_ms = round((perf_counter() - start) * 1000, 3)
|
||||
logger.error(
|
||||
"🚨 Error recuperando la sesión previa desde SQLite",
|
||||
add_fields={
|
||||
"error": str(error),
|
||||
"agent_call": {
|
||||
"action": "restore_session",
|
||||
"agent_name": self.name,
|
||||
"user_id": self.user_id,
|
||||
},
|
||||
"agent_response": {
|
||||
"status": "error",
|
||||
"duration_ms": duration_ms,
|
||||
},
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.warn(
|
||||
"⚠️ Agente sin base de datos configurada; la memoria no se persistirá",
|
||||
add_fields={
|
||||
"agent_call": {
|
||||
"action": "configure_memory",
|
||||
"agent_name": self.name,
|
||||
},
|
||||
"agent_response": {
|
||||
"status": "db_missing",
|
||||
"add_history_to_context": self.memory_config.get("add_history_to_context", False),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
api_key = os.getenv("OPENAI_API_KEY")
|
||||
if not api_key:
|
||||
logger.warn(
|
||||
"⚠️ OPENAI_API_KEY no encontrado; el agente puede fallar al invocar el modelo",
|
||||
add_fields={
|
||||
"agent_call": {
|
||||
"action": "load_model_credentials",
|
||||
"agent_name": self.name,
|
||||
},
|
||||
"agent_response": {"status": "missing_credentials"},
|
||||
},
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
model=OpenAIChat(
|
||||
id=self.model_id,
|
||||
api_key=api_key,
|
||||
**self.model_kwargs,
|
||||
),
|
||||
db=self.db,
|
||||
user_id=self.user_id,
|
||||
session_id=self.session_id,
|
||||
tools=[*local_tools, *mcp_tools],
|
||||
markdown=self.markdown,
|
||||
name=self.name,
|
||||
description=self.description,
|
||||
system_message=self.system_prompt,
|
||||
debug_mode=self.debug_mode,
|
||||
telemetry=self.telemetry,
|
||||
**self.memory_config,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"🤖 Agente configurado",
|
||||
add_fields={
|
||||
"agent_call": {
|
||||
"action": "create_agent",
|
||||
"agent_name": self.name,
|
||||
"local_tools": [tool.__name__ for tool in local_tools],
|
||||
"mcp_servers": active_mcp_servers,
|
||||
},
|
||||
"agent_response": {
|
||||
"status": "created",
|
||||
"tool_count": len(agent.tools) if agent.tools else 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Sesión del agente lista para usarse",
|
||||
add_fields={
|
||||
"agent_call": {
|
||||
"action": "session_ready",
|
||||
"agent_name": self.name,
|
||||
},
|
||||
"agent_response": {
|
||||
"status": "ready",
|
||||
"session_id": getattr(agent, "session_id", self.session_id),
|
||||
"user_id": self.user_id,
|
||||
"db_file": getattr(self.db, "db_file", None) if self.db else None,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
context = {
|
||||
"mcp_tools": mcp_tools,
|
||||
"active_servers": active_mcp_servers,
|
||||
"server_tool_map": server_tool_map,
|
||||
"local_tools": [tool.__name__ for tool in local_tools],
|
||||
"agent_name": self.name,
|
||||
"db_file": getattr(self.db, "db_file", None) if self.db else None,
|
||||
"user_id": self.user_id,
|
||||
"session_id": getattr(agent, "session_id", self.session_id),
|
||||
"resume_previous_session": self.resume_previous_session,
|
||||
}
|
||||
self.session_id = context["session_id"]
|
||||
|
||||
return agent, context
|
||||
|
||||
|
||||
def _wrapper_from_definition(definition: AgentDefinition) -> AgentWrapper:
|
||||
return AgentWrapper(
|
||||
name=definition.name,
|
||||
description=definition.description,
|
||||
system_prompt=definition.system_prompt,
|
||||
model_id=definition.model_id,
|
||||
memory_config=definition.memory_config,
|
||||
mcp_config=definition.mcp_config,
|
||||
tool_factories=definition.tool_factories,
|
||||
markdown=definition.markdown,
|
||||
debug_mode=definition.debug_mode,
|
||||
telemetry=definition.telemetry,
|
||||
model_kwargs=definition.model_kwargs,
|
||||
db=definition.db,
|
||||
user_id=definition.user_id,
|
||||
session_id=definition.session_id,
|
||||
resume_previous_session=definition.resume_previous_session,
|
||||
)
|
||||
|
||||
|
||||
_AGENT_DEFINITIONS = [
|
||||
AgenteBasico(),
|
||||
]
|
||||
|
||||
DEFAULT_AGENT_NAME = _AGENT_DEFINITIONS[0].get_registry_key()
|
||||
|
||||
AGENT_REGISTRY: Dict[str, AgentWrapper] = {
|
||||
agent.get_registry_key(): _wrapper_from_definition(agent.build_definition())
|
||||
for agent in _AGENT_DEFINITIONS
|
||||
}
|
||||
|
||||
|
||||
def register_agent(key: str, wrapper: AgentWrapper) -> None:
|
||||
AGENT_REGISTRY[key.lower()] = wrapper
|
||||
|
||||
|
||||
def get_agent_wrapper(agent: Union[str, AgentWrapper, None]) -> Tuple[str, AgentWrapper]:
|
||||
if isinstance(agent, AgentWrapper):
|
||||
return agent.name.lower(), agent
|
||||
agent_key = (agent or DEFAULT_AGENT_NAME).lower()
|
||||
if agent_key not in AGENT_REGISTRY:
|
||||
raise KeyError(f"No se encontró un agente registrado con la clave '{agent_key}'")
|
||||
return agent_key, AGENT_REGISTRY[agent_key]
|
||||
|
||||
|
||||
async def create_agent(logger, agent: Union[str, AgentWrapper, None] = None) -> Tuple[Agent, Dict[str, Any]]:
|
||||
agent_key, wrapper = get_agent_wrapper(agent)
|
||||
agent_instance, context = await wrapper.create(logger)
|
||||
context.setdefault("agent_name", wrapper.name)
|
||||
context.setdefault("agent_key", agent_key)
|
||||
return agent_instance, context
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Definición del agente básico de ejemplo."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .base import AgenteBase, ToolFactory
|
||||
|
||||
SYSTEM_PROMPT = ("""## 🧠 System Prompt — “Agente Kanboard”
|
||||
|
||||
**Nombre del agente:** KAN
|
||||
**Rol:** Asistente autónomo de productividad y coordinación de tareas en Kanboard.
|
||||
|
||||
---
|
||||
|
||||
### 🎯 Objetivo principal
|
||||
|
||||
Gestionar, organizar y optimizar las tareas de los proyectos en Kanboard.
|
||||
Debe actuar como un gestor de proyectos proactivo, ayudando a mantener el flujo de trabajo limpio, priorizado y actualizado.
|
||||
|
||||
---
|
||||
|
||||
### 💼 Responsabilidades
|
||||
|
||||
1. **Gestión de tareas**
|
||||
|
||||
* Crear, actualizar, mover y cerrar tareas según el estado de avance.
|
||||
* Asignar responsables, etiquetas y fechas límite con base en contexto o patrones previos.
|
||||
* Detectar tareas duplicadas o bloqueadas y sugerir acciones correctivas.
|
||||
|
||||
2. **Priorización inteligente**
|
||||
|
||||
* Ordenar tareas usando criterios de impacto, urgencia y dependencias.
|
||||
* Recalcular prioridades automáticamente si cambian fechas o recursos.
|
||||
|
||||
3. **Contexto y comunicación**
|
||||
|
||||
* Resumir el estado actual de cada proyecto.
|
||||
* Generar reportes diarios o semanales con progreso, riesgos y tareas pendientes.
|
||||
* Explicar cambios recientes en el tablero (por ejemplo: “se movieron 3 tareas a *Done*”).
|
||||
|
||||
4. **Automatización de flujo**
|
||||
|
||||
* Mover tareas automáticamente al siguiente estado según condiciones predefinidas (por ejemplo: “si los tests pasan, mover a *Ready for Review*”).
|
||||
* Crear subtareas recurrentes o tareas de seguimiento.
|
||||
|
||||
---
|
||||
|
||||
### 🧩 Entradas esperadas
|
||||
|
||||
El agente debe ser capaz de interpretar comandos naturales como:
|
||||
|
||||
* “Crea una tarea para configurar el pipeline de ingestión en Kafka.”
|
||||
* “Muévela a *Doing* y asígnamela.”
|
||||
* “Resúmeme el tablero del proyecto *Data Lake*.”
|
||||
* “Prioriza las tareas de optimización antes que las de documentación.”
|
||||
* “Muéstrame las tareas bloqueadas desde hace más de 3 días.”
|
||||
|
||||
---
|
||||
|
||||
### 🔍 Integraciones
|
||||
|
||||
* **Kanboard API:** CRUD de tareas, columnas, etiquetas, usuarios y comentarios.
|
||||
* **GitHub / GitLab:** Referencias automáticas a commits o PRs relacionados.
|
||||
* **Slack / Email:** Notificaciones opcionales sobre cambios importantes.
|
||||
* **Calendario / Notion (opcional):** Sincronización de deadlines y notas de planificación.
|
||||
|
||||
---
|
||||
|
||||
### 🧠 Personalidad y estilo
|
||||
|
||||
* **Tono:** Profesional, directo, pero con iniciativa.
|
||||
* **Comunicación:** Clara, contextual, priorizando acciones útiles.
|
||||
* **Criterio:** Detecta inconsistencias y las menciona sin esperar instrucción.
|
||||
(Ej: “La tarea ‘Configurar ETL’ lleva 10 días en *Doing*, ¿quieres revisarla?”)
|
||||
|
||||
---
|
||||
|
||||
### ⚙️ Reglas de operación
|
||||
|
||||
1. No crea ni elimina proyectos sin confirmación explícita.
|
||||
2. Puede sugerir cambios automáticos, pero siempre explica el motivo.
|
||||
3. Mantiene trazabilidad de todas las acciones.
|
||||
4. Respeta el esquema de columnas estándar:
|
||||
|
||||
* **Backlog → Ready → Doing → Review → Done**
|
||||
5. Si un comando es ambiguo, pregunta antes de actuar.
|
||||
6. Todas las respuestas deben incluir **acciones sugeridas o contexto útil** (no solo datos).
|
||||
|
||||
---
|
||||
|
||||
### 📈 Objetivo secundario
|
||||
|
||||
Aprender del comportamiento del usuario (por ejemplo, patrones de asignación o tiempos de ciclo) para **recomendar automatizaciones personalizadas**, como:
|
||||
|
||||
* Recordatorios automáticos.
|
||||
* Repriorización dinámica.
|
||||
* Creación de plantillas de tareas repetitivas.
|
||||
|
||||
|
||||
"""
|
||||
|
||||
)
|
||||
|
||||
DEFAULT_MEMORY_CONFIG: Dict[str, Any] = {
|
||||
"add_history_to_context": True,
|
||||
"num_history_messages": 10,
|
||||
"store_history_messages": True,
|
||||
}
|
||||
|
||||
DEFAULT_MCP_CONFIG: Dict[str, Any] = {
|
||||
"mcpServers": {
|
||||
"time": {
|
||||
"command": "/home/lucas/DataProyects/kanboard/.venv/bin/python",
|
||||
"args": ["/home/lucas/DataProyects/kanboard/kanboard_mcp.py"],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# def _create_multiplicar_tool(logger) -> ToolFactory:
|
||||
# def multiplicar(a: float, b: float) -> float:
|
||||
# tool_call_data = {
|
||||
# "tool_name": "multiplicar",
|
||||
# "params": {"a": a, "b": b},
|
||||
# "action": "multiply_numbers",
|
||||
# }
|
||||
# logger.info(
|
||||
# "🛠️ Tool llamada: multiplicar",
|
||||
# add_fields={"agent_call": tool_call_data},
|
||||
# )
|
||||
|
||||
# resultado = a * b
|
||||
|
||||
# tool_response_data = {
|
||||
# "tool_name": "multiplicar",
|
||||
# "response": resultado,
|
||||
# "success": True,
|
||||
# "operation": f"{a} × {b} = {resultado}",
|
||||
# }
|
||||
# logger.info(
|
||||
# "✅ Tool respuesta: multiplicar completada exitosamente",
|
||||
# add_fields={"agent_response": tool_response_data},
|
||||
# )
|
||||
|
||||
# return resultado
|
||||
|
||||
# multiplicar.__name__ = "multiplicar"
|
||||
# return multiplicar
|
||||
|
||||
|
||||
class AgenteBasico(AgenteBase):
|
||||
key = "kan"
|
||||
name = "Kan"
|
||||
description = "Agente de tareas para gestionar mi tablero kanboard"
|
||||
system_prompt = SYSTEM_PROMPT
|
||||
model_id = "gpt-4o"
|
||||
markdown = True
|
||||
debug_mode = True
|
||||
telemetry = False
|
||||
|
||||
def get_memory_config(self) -> Dict[str, Any]:
|
||||
return dict(DEFAULT_MEMORY_CONFIG)
|
||||
|
||||
def get_mcp_config(self) -> Dict[str, Any]:
|
||||
return dict(DEFAULT_MCP_CONFIG)
|
||||
|
||||
# def get_tool_factories(self) -> List[ToolFactory]:
|
||||
# return [_create_multiplicar_tool]
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
"""Clases base para definir agentes del proyecto."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from agno.db.sqlite import SqliteDb
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ToolFactory = Callable[[Any], Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentDefinition:
|
||||
"""Contenedor ligero con los parámetros para construir un AgentWrapper."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
system_prompt: str
|
||||
model_id: str
|
||||
memory_config: Dict[str, Any]
|
||||
mcp_config: Optional[Dict[str, Any]]
|
||||
tool_factories: List[ToolFactory]
|
||||
markdown: bool
|
||||
debug_mode: bool
|
||||
telemetry: bool
|
||||
model_kwargs: Dict[str, Any]
|
||||
db: Optional[SqliteDb]
|
||||
user_id: Optional[str]
|
||||
session_id: Optional[str]
|
||||
resume_previous_session: bool
|
||||
|
||||
|
||||
class AgenteBase:
|
||||
"""Proporciona valores por defecto y utilidades para los agentes concretos."""
|
||||
|
||||
key: str = "base"
|
||||
name: str = "Agente base"
|
||||
description: str = "Agente genérico."
|
||||
system_prompt: str = ""
|
||||
model_id: str = "gpt-4o"
|
||||
markdown: bool = True
|
||||
debug_mode: bool = False
|
||||
telemetry: bool = False
|
||||
memory_config: Dict[str, Any] = {}
|
||||
mcp_config: Optional[Dict[str, Any]] = None
|
||||
model_kwargs: Dict[str, Any] = {}
|
||||
db_path: Optional[str] = "memoria/conversaciones.db"
|
||||
user_id: Optional[str] = None
|
||||
resume_previous_session: bool = True
|
||||
|
||||
def get_registry_key(self) -> str:
|
||||
"""Clave utilizada en el registro global de agentes."""
|
||||
return self.key.lower()
|
||||
|
||||
def get_memory_config(self) -> Dict[str, Any]:
|
||||
return dict(self.memory_config) if self.memory_config else {}
|
||||
|
||||
def get_mcp_config(self) -> Optional[Dict[str, Any]]:
|
||||
if not self.mcp_config:
|
||||
return None
|
||||
return {**self.mcp_config}
|
||||
|
||||
def get_tool_factories(self) -> List[ToolFactory]:
|
||||
return []
|
||||
|
||||
def get_model_kwargs(self) -> Dict[str, Any]:
|
||||
return dict(self.model_kwargs) if self.model_kwargs else {}
|
||||
|
||||
def _resolve_db_path(self) -> Optional[Path]:
|
||||
if not self.db_path:
|
||||
return None
|
||||
db_path = Path(self.db_path)
|
||||
if not db_path.is_absolute():
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
db_path = project_root / db_path
|
||||
try:
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as error:
|
||||
logger.warning(
|
||||
"⚠️ No se pudo crear el directorio para la base de datos SQLite",
|
||||
extra={"db_directory": str(db_path.parent), "error": str(error)},
|
||||
)
|
||||
return None
|
||||
return db_path
|
||||
|
||||
def get_database(self) -> Optional[SqliteDb]:
|
||||
db_path = self._resolve_db_path()
|
||||
if not db_path:
|
||||
return None
|
||||
return SqliteDb(db_file=str(db_path))
|
||||
|
||||
def get_user_id(self) -> str:
|
||||
return self.user_id or self.get_registry_key()
|
||||
|
||||
def build_definition(self) -> AgentDefinition:
|
||||
"""Genera los parámetros necesarios para instanciar un AgentWrapper."""
|
||||
db = self.get_database()
|
||||
return AgentDefinition(
|
||||
name=self.name,
|
||||
description=self.description,
|
||||
system_prompt=self.system_prompt,
|
||||
model_id=self.model_id,
|
||||
memory_config=self.get_memory_config(),
|
||||
mcp_config=self.get_mcp_config(),
|
||||
tool_factories=list(self.get_tool_factories()),
|
||||
markdown=self.markdown,
|
||||
debug_mode=self.debug_mode,
|
||||
telemetry=self.telemetry,
|
||||
model_kwargs=self.get_model_kwargs(),
|
||||
db=db,
|
||||
user_id=self.get_user_id(),
|
||||
session_id=None,
|
||||
resume_previous_session=self.resume_previous_session,
|
||||
)
|
||||
Reference in New Issue
Block a user