Route memory action
This commit is contained in:
240
app.py
240
app.py
@@ -16,6 +16,7 @@ from memory import (
|
|||||||
add_repository,
|
add_repository,
|
||||||
add_task,
|
add_task,
|
||||||
close_task,
|
close_task,
|
||||||
|
deactivate_client,
|
||||||
get_memory_snapshot,
|
get_memory_snapshot,
|
||||||
init_db,
|
init_db,
|
||||||
list_clients,
|
list_clients,
|
||||||
@@ -65,6 +66,7 @@ Tu disposes aussi d'une memoire professionnelle persistante pour les clients, pr
|
|||||||
Quand l'utilisateur te demande de noter, memoriser, ajouter ou associer une information professionnelle, reponds uniquement avec un JSON.
|
Quand l'utilisateur te demande de noter, memoriser, ajouter ou associer une information professionnelle, reponds uniquement avec un JSON.
|
||||||
Actions disponibles :
|
Actions disponibles :
|
||||||
{"action": "ajouter_client", "nom": "...", "notes": "...", "statut": "actif"}
|
{"action": "ajouter_client", "nom": "...", "notes": "...", "statut": "actif"}
|
||||||
|
{"action": "supprimer_client", "nom": "..."}
|
||||||
{"action": "ajouter_projet", "nom": "...", "client": "...", "statut": "...", "resume": "...", "prochaine_action": "..."}
|
{"action": "ajouter_projet", "nom": "...", "client": "...", "statut": "...", "resume": "...", "prochaine_action": "..."}
|
||||||
{"action": "ajouter_tache", "titre": "...", "client": "...", "projet": "...", "details": "...", "echeance": "..."}
|
{"action": "ajouter_tache", "titre": "...", "client": "...", "projet": "...", "details": "...", "echeance": "..."}
|
||||||
{"action": "terminer_tache", "id": 123}
|
{"action": "terminer_tache", "id": 123}
|
||||||
@@ -84,6 +86,50 @@ Pour tout le reste, reponds en francais naturel, avec des phrases courtes.
|
|||||||
N'utilise pas d'anglais sauf si l'utilisateur le demande explicitement.
|
N'utilise pas d'anglais sauf si l'utilisateur le demande explicitement.
|
||||||
En mode voiture, sois bref : une ou deux phrases maximum."""
|
En mode voiture, sois bref : une ou deux phrases maximum."""
|
||||||
|
|
||||||
|
ROUTER_PROMPT = """Tu es le routeur d'intention de l'assistant personnel de Laurent.
|
||||||
|
Ton role est de comparer le message utilisateur avec la liste des actions disponibles et le contexte fourni.
|
||||||
|
|
||||||
|
Reponds uniquement avec un JSON valide, sans markdown.
|
||||||
|
|
||||||
|
Format obligatoire :
|
||||||
|
{
|
||||||
|
"type": "action" | "answer" | "clarification",
|
||||||
|
"confidence": 0.0,
|
||||||
|
"action": null | {"action": "..."} | {"actions": [{"action": "..."}]},
|
||||||
|
"reply": "",
|
||||||
|
"question": ""
|
||||||
|
}
|
||||||
|
|
||||||
|
Actions disponibles :
|
||||||
|
- envoyer_email: {"action":"envoyer_email","destinataire":"...","sujet":"...","corps":"..."}
|
||||||
|
- ajouter_contact: {"action":"ajouter_contact","nom":"...","email":"..."}
|
||||||
|
- ajouter_client: {"action":"ajouter_client","nom":"...","notes":"...","statut":"actif"}
|
||||||
|
- supprimer_client: {"action":"supprimer_client","nom":"..."}
|
||||||
|
- ajouter_projet: {"action":"ajouter_projet","nom":"...","client":"...","statut":"...","resume":"...","prochaine_action":"..."}
|
||||||
|
- ajouter_tache: {"action":"ajouter_tache","titre":"...","client":"...","projet":"...","details":"...","echeance":"..."}
|
||||||
|
- terminer_tache: {"action":"terminer_tache","id":123}
|
||||||
|
- ajouter_repo: {"action":"ajouter_repo","nom":"...","projet":"...","chemin":"...","url":"...","branche":"..."}
|
||||||
|
- ajouter_note: {"action":"ajouter_note","contenu":"...","sujet":"...","client":"...","projet":"...","type":"note"}
|
||||||
|
- chercher_memoire: {"action":"chercher_memoire","requete":"..."}
|
||||||
|
- lister_clients: {"action":"lister_clients"}
|
||||||
|
- analyser_taches: {"action":"analyser_taches","type":"client_plus_taches"}
|
||||||
|
|
||||||
|
Regles :
|
||||||
|
- Choisis une action seulement si l'intention et les arguments sont clairs.
|
||||||
|
- Si plusieurs actions sont necessaires, utilise {"actions":[...]}.
|
||||||
|
- Si l'utilisateur demande une modification mais que la cible est ambigue, type="clarification".
|
||||||
|
- Si l'utilisateur pose une question generale et que le contexte suffit, type="answer" et reponds directement.
|
||||||
|
- Si une action de lecture convient mieux qu'une reponse libre, type="action".
|
||||||
|
- Si aucune action ne correspond, type="answer".
|
||||||
|
- confidence >= 0.80 pour executer une action sans confirmation.
|
||||||
|
- confidence entre 0.45 et 0.79 : type="clarification".
|
||||||
|
- N'invente pas d'information absente du contexte. Si la demande porte sur une categorie inconnue, demande une precision.
|
||||||
|
- Pour "nouveaux clients", ne suppose pas que tous les clients sont nouveaux. Demande si Laurent veut les derniers ajoutes, tous les clients, ou une notion precise de nouveau.
|
||||||
|
- Pour supprimer/terminer une tache, utilise l'id si le contexte de conversation contient les dernieres taches affichees.
|
||||||
|
- Pour retirer/enlever/supprimer un client de la liste, utilise supprimer_client.
|
||||||
|
- Pour "quel client a le plus de taches", utilise analyser_taches.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def charger_contacts():
|
def charger_contacts():
|
||||||
try:
|
try:
|
||||||
@@ -186,6 +232,7 @@ ACTIONS = {
|
|||||||
"envoyer_email",
|
"envoyer_email",
|
||||||
"ajouter_contact",
|
"ajouter_contact",
|
||||||
"ajouter_client",
|
"ajouter_client",
|
||||||
|
"supprimer_client",
|
||||||
"ajouter_projet",
|
"ajouter_projet",
|
||||||
"ajouter_tache",
|
"ajouter_tache",
|
||||||
"terminer_tache",
|
"terminer_tache",
|
||||||
@@ -242,6 +289,99 @@ def demander_au_llm(session_id, texte):
|
|||||||
return contenu
|
return contenu
|
||||||
|
|
||||||
|
|
||||||
|
def router_intention(session_id, texte):
|
||||||
|
historique = histories.get(session_id, [])[-12:]
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": ROUTER_PROMPT},
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": (
|
||||||
|
"Contexte disponible pour router la demande :\n"
|
||||||
|
+ construire_contexte_routeur(session_id)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] + historique + [{"role": "user", "content": texte}]
|
||||||
|
|
||||||
|
reponse = client.chat.completions.create(
|
||||||
|
model=CHAT_MODEL,
|
||||||
|
messages=messages,
|
||||||
|
temperature=0.0,
|
||||||
|
max_tokens=700,
|
||||||
|
)
|
||||||
|
contenu = reponse.choices[0].message.content or ""
|
||||||
|
data = extraire_json_routeur(contenu)
|
||||||
|
if not data:
|
||||||
|
return {
|
||||||
|
"type": "answer",
|
||||||
|
"confidence": 0.0,
|
||||||
|
"action": None,
|
||||||
|
"reply": contenu.strip() or "Je n'ai pas compris. Tu peux reformuler ?",
|
||||||
|
"question": "",
|
||||||
|
}
|
||||||
|
return normaliser_decision_routeur(data)
|
||||||
|
|
||||||
|
|
||||||
|
def construire_contexte_routeur(session_id=None):
|
||||||
|
contacts = charger_contacts()
|
||||||
|
contacts_connus = "\n".join(f"- {nom.title()} : {email}" for nom, email in contacts.items())
|
||||||
|
return (
|
||||||
|
"Contacts email connus :\n"
|
||||||
|
+ (contacts_connus or "- aucun")
|
||||||
|
+ "\n\nMemoire professionnelle actuelle :\n"
|
||||||
|
+ construire_contexte_memoire(session_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extraire_json_routeur(contenu):
|
||||||
|
decodeur = json.JSONDecoder()
|
||||||
|
for index, caractere in enumerate(contenu):
|
||||||
|
if caractere != "{":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data, _ = decodeur.raw_decode(contenu[index:])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def normaliser_decision_routeur(data):
|
||||||
|
type_decision = data.get("type") or "answer"
|
||||||
|
if type_decision not in {"action", "answer", "clarification"}:
|
||||||
|
type_decision = "answer"
|
||||||
|
|
||||||
|
try:
|
||||||
|
confidence = float(data.get("confidence", 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
confidence = 0.0
|
||||||
|
confidence = max(0.0, min(1.0, confidence))
|
||||||
|
|
||||||
|
action = data.get("action")
|
||||||
|
if isinstance(action, dict) and not action_valide(action):
|
||||||
|
action = None
|
||||||
|
|
||||||
|
if type_decision == "action" and (confidence < 0.80 or not action):
|
||||||
|
type_decision = "clarification"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": type_decision,
|
||||||
|
"confidence": confidence,
|
||||||
|
"action": action,
|
||||||
|
"reply": str(data.get("reply") or "").strip(),
|
||||||
|
"question": str(data.get("question") or "").strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def action_valide(action):
|
||||||
|
if action.get("action") in ACTIONS:
|
||||||
|
return True
|
||||||
|
actions = action.get("actions")
|
||||||
|
if isinstance(actions, list) and actions:
|
||||||
|
return all(isinstance(item, dict) and item.get("action") in ACTIONS for item in actions)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def detecter_action_directe(texte, session_id=None):
|
def detecter_action_directe(texte, session_id=None):
|
||||||
texte_nettoye = texte.strip()
|
texte_nettoye = texte.strip()
|
||||||
texte_min = texte_nettoye.lower()
|
texte_min = texte_nettoye.lower()
|
||||||
@@ -260,6 +400,10 @@ def detecter_action_directe(texte, session_id=None):
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suppression_client = detecter_suppression_client(texte_nettoye)
|
||||||
|
if suppression_client:
|
||||||
|
return suppression_client
|
||||||
|
|
||||||
analyse_taches = detecter_analyse_taches(texte_nettoye)
|
analyse_taches = detecter_analyse_taches(texte_nettoye)
|
||||||
if analyse_taches:
|
if analyse_taches:
|
||||||
return analyse_taches
|
return analyse_taches
|
||||||
@@ -369,6 +513,26 @@ def detecter_tache_client(texte):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def detecter_suppression_client(texte):
|
||||||
|
if not re.search(r"\b(enleve|enlève|supprime|retire|efface|desactive|désactive)\b", texte, flags=re.IGNORECASE):
|
||||||
|
return None
|
||||||
|
if re.search(r"\btaches?\b|\bprojets?\b|\brepos?\b|\bdepots?\b|\bdépôts?\b", texte, flags=re.IGNORECASE):
|
||||||
|
return None
|
||||||
|
|
||||||
|
match = re.search(
|
||||||
|
r"\b(?:enleve|enlève|supprime|retire|efface|desactive|désactive)\s+(?:le\s+client\s+|client\s+)?(.+?)(?:\s+(?:de|dans)\s+(?:la\s+)?liste.*)?$",
|
||||||
|
texte,
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
nom = nettoyer_nom_client(match.group(1))
|
||||||
|
if not nom:
|
||||||
|
return None
|
||||||
|
return {"action": "supprimer_client", "nom": nom}
|
||||||
|
|
||||||
|
|
||||||
def detecter_recherche_taches(texte):
|
def detecter_recherche_taches(texte):
|
||||||
if re.search(
|
if re.search(
|
||||||
r"\b(pour\s+quel\s+client|quel\s+client|plus\s+de\s+taches?|plus\s+de\s+tâches?|combien|classement|top)\b",
|
r"\b(pour\s+quel\s+client|quel\s+client|plus\s+de\s+taches?|plus\s+de\s+tâches?|combien|classement|top)\b",
|
||||||
@@ -496,6 +660,12 @@ def separer_noms_clients(texte):
|
|||||||
|
|
||||||
def nettoyer_nom_client(nom):
|
def nettoyer_nom_client(nom):
|
||||||
nom = nom.strip(" -\t\r.,;:")
|
nom = nom.strip(" -\t\r.,;:")
|
||||||
|
nom = re.sub(
|
||||||
|
r"\s+\b(je|j'ai|j ai|stp|svp|s'il te plait|s'il te plaît|merci)\b.*$",
|
||||||
|
"",
|
||||||
|
nom,
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
).strip(" -\t\r.,;:")
|
||||||
if not nom or len(nom) < 2:
|
if not nom or len(nom) < 2:
|
||||||
return ""
|
return ""
|
||||||
if re.search(r"\b(liste|clients?|voici|note|notes|noter|quelque|part)\b", nom, flags=re.IGNORECASE):
|
if re.search(r"\b(liste|clients?|voici|note|notes|noter|quelque|part)\b", nom, flags=re.IGNORECASE):
|
||||||
@@ -580,6 +750,13 @@ def executer_action(action):
|
|||||||
)
|
)
|
||||||
return f"C'est note : {client_mem['name']} est dans tes clients.", {"memorySaved": True}
|
return f"C'est note : {client_mem['name']} est dans tes clients.", {"memorySaved": True}
|
||||||
|
|
||||||
|
if nom_action == "supprimer_client":
|
||||||
|
client_mem = deactivate_client(action.get("nom", ""))
|
||||||
|
nom = action.get("nom", "").strip()
|
||||||
|
if not client_mem:
|
||||||
|
return f"Je n'ai pas trouve le client {nom} dans ta liste.", {"memoryRead": True}
|
||||||
|
return f"C'est fait : {client_mem['name']} n'apparaitra plus dans tes clients actifs.", {"memorySaved": True}
|
||||||
|
|
||||||
if nom_action == "ajouter_projet":
|
if nom_action == "ajouter_projet":
|
||||||
projet = upsert_project(
|
projet = upsert_project(
|
||||||
action.get("nom", ""),
|
action.get("nom", ""),
|
||||||
@@ -695,11 +872,7 @@ def executer_actions(action_data):
|
|||||||
|
|
||||||
|
|
||||||
def mettre_a_jour_contexte(session_id, texte, reponse, flags):
|
def mettre_a_jour_contexte(session_id, texte, reponse, flags):
|
||||||
historique = histories.setdefault(session_id, [])
|
ajouter_echange_historique(session_id, texte, reponse)
|
||||||
if not historique or historique[-1].get("content") != texte:
|
|
||||||
historique.append({"role": "user", "content": texte})
|
|
||||||
historique.append({"role": "assistant", "content": reponse})
|
|
||||||
del historique[:-20]
|
|
||||||
|
|
||||||
resultat = flags.get("memoryResult")
|
resultat = flags.get("memoryResult")
|
||||||
if not resultat:
|
if not resultat:
|
||||||
@@ -717,6 +890,14 @@ def mettre_a_jour_contexte(session_id, texte, reponse, flags):
|
|||||||
contexte["tasks"] = resultat.get("tasks", [])
|
contexte["tasks"] = resultat.get("tasks", [])
|
||||||
|
|
||||||
|
|
||||||
|
def ajouter_echange_historique(session_id, texte, reponse):
|
||||||
|
historique = histories.setdefault(session_id, [])
|
||||||
|
if not historique or historique[-1].get("content") != texte:
|
||||||
|
historique.append({"role": "user", "content": texte})
|
||||||
|
historique.append({"role": "assistant", "content": reponse})
|
||||||
|
del historique[:-20]
|
||||||
|
|
||||||
|
|
||||||
def formater_memoire(resultat):
|
def formater_memoire(resultat):
|
||||||
lignes = []
|
lignes = []
|
||||||
for client_mem in resultat["clients"][:5]:
|
for client_mem in resultat["clients"][:5]:
|
||||||
@@ -787,9 +968,21 @@ def formater_client_plus_taches(snapshot):
|
|||||||
|
|
||||||
|
|
||||||
def traiter_message(session_id, texte, avec_audio=False):
|
def traiter_message(session_id, texte, avec_audio=False):
|
||||||
action_directe = detecter_action_directe(texte, session_id)
|
action_confirmee = recuperer_action_confirmee(session_id, texte)
|
||||||
reponse = json.dumps(action_directe, ensure_ascii=False) if action_directe else demander_au_llm(session_id, texte)
|
if action_confirmee:
|
||||||
action = extraire_action(reponse)
|
reponse, flags = executer_actions(action_confirmee)
|
||||||
|
mettre_a_jour_contexte(session_id, texte, reponse, flags)
|
||||||
|
audio_url = generer_audio(reponse) if avec_audio and TTS_PROVIDER == "groq" else None
|
||||||
|
return {
|
||||||
|
"reply": reponse,
|
||||||
|
"audioUrl": audio_url,
|
||||||
|
"emailSent": flags["emailSent"],
|
||||||
|
"contactSaved": flags["contactSaved"],
|
||||||
|
"memorySaved": flags["memorySaved"],
|
||||||
|
"memoryRead": flags["memoryRead"],
|
||||||
|
}
|
||||||
|
|
||||||
|
decision = router_intention(session_id, texte)
|
||||||
flags = {
|
flags = {
|
||||||
"emailSent": False,
|
"emailSent": False,
|
||||||
"contactSaved": False,
|
"contactSaved": False,
|
||||||
@@ -799,9 +992,20 @@ def traiter_message(session_id, texte, avec_audio=False):
|
|||||||
"memoryQuery": "",
|
"memoryQuery": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
if action:
|
if decision["type"] == "action":
|
||||||
reponse, flags = executer_actions(action)
|
reponse, flags = executer_actions(decision["action"])
|
||||||
mettre_a_jour_contexte(session_id, texte, reponse, flags)
|
mettre_a_jour_contexte(session_id, texte, reponse, flags)
|
||||||
|
elif decision["type"] == "clarification":
|
||||||
|
reponse = decision["question"] or decision["reply"] or "Tu peux préciser ce que tu veux que je fasse ?"
|
||||||
|
if decision.get("action"):
|
||||||
|
session_contexts.setdefault(session_id, {})["pending_action"] = decision["action"]
|
||||||
|
ajouter_echange_historique(session_id, texte, reponse)
|
||||||
|
else:
|
||||||
|
reponse = decision["reply"]
|
||||||
|
if not reponse:
|
||||||
|
reponse = demander_au_llm(session_id, texte)
|
||||||
|
else:
|
||||||
|
ajouter_echange_historique(session_id, texte, reponse)
|
||||||
|
|
||||||
audio_url = None
|
audio_url = None
|
||||||
if avec_audio and TTS_PROVIDER == "groq":
|
if avec_audio and TTS_PROVIDER == "groq":
|
||||||
@@ -817,6 +1021,22 @@ def traiter_message(session_id, texte, avec_audio=False):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def recuperer_action_confirmee(session_id, texte):
|
||||||
|
contexte = session_contexts.get(session_id, {})
|
||||||
|
pending_action = contexte.get("pending_action")
|
||||||
|
if not pending_action:
|
||||||
|
return None
|
||||||
|
|
||||||
|
texte_min = texte.strip().lower()
|
||||||
|
if re.fullmatch(r"(oui|yes|ok|d'accord|vas-y|confirme|c'est ca|c'est ça|exact|tout a fait|tout à fait)[\s.!?]*", texte_min):
|
||||||
|
contexte.pop("pending_action", None)
|
||||||
|
return pending_action
|
||||||
|
if re.fullmatch(r"(non|no|annule|stop|laisse tomber|pas ca|pas ça)[\s.!?]*", texte_min):
|
||||||
|
contexte.pop("pending_action", None)
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def index():
|
def index():
|
||||||
return send_from_directory(STATIC_DIR, "index.html")
|
return send_from_directory(STATIC_DIR, "index.html")
|
||||||
|
|||||||
28
memory.py
28
memory.py
@@ -132,6 +132,26 @@ def upsert_client(name, notes="", status="actif"):
|
|||||||
return row_to_dict(db.execute("SELECT * FROM clients WHERE id = ?", (client_id,)).fetchone())
|
return row_to_dict(db.execute("SELECT * FROM clients WHERE id = ?", (client_id,)).fetchone())
|
||||||
|
|
||||||
|
|
||||||
|
def deactivate_client(name):
|
||||||
|
name = clean_text(name)
|
||||||
|
if not name:
|
||||||
|
raise ValueError("Nom de client manquant")
|
||||||
|
|
||||||
|
with connect() as db:
|
||||||
|
row = find_client(db, name)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE clients
|
||||||
|
SET status = 'inactif', updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(row["id"],),
|
||||||
|
)
|
||||||
|
return row_to_dict(row)
|
||||||
|
|
||||||
|
|
||||||
def upsert_project(name, client_name="", status="actif", summary="", next_action=""):
|
def upsert_project(name, client_name="", status="actif", summary="", next_action=""):
|
||||||
name = clean_text(name)
|
name = clean_text(name)
|
||||||
if not name:
|
if not name:
|
||||||
@@ -198,7 +218,12 @@ def add_task(title, client_name="", project_name="", details="", due_at=""):
|
|||||||
def list_clients(limit=100):
|
def list_clients(limit=100):
|
||||||
with connect() as db:
|
with connect() as db:
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
"SELECT * FROM clients ORDER BY name COLLATE NOCASE LIMIT ?",
|
"""
|
||||||
|
SELECT * FROM clients
|
||||||
|
WHERE status != 'inactif'
|
||||||
|
ORDER BY name COLLATE NOCASE
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
(limit,),
|
(limit,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [row_to_dict(row) for row in rows]
|
return [row_to_dict(row) for row in rows]
|
||||||
@@ -212,6 +237,7 @@ def get_memory_snapshot(limit=25):
|
|||||||
COUNT(t.id) AS open_task_count
|
COUNT(t.id) AS open_task_count
|
||||||
FROM clients c
|
FROM clients c
|
||||||
LEFT JOIN tasks t ON t.client_id = c.id AND t.done = 0
|
LEFT JOIN tasks t ON t.client_id = c.id AND t.done = 0
|
||||||
|
WHERE c.status != 'inactif'
|
||||||
GROUP BY c.id
|
GROUP BY c.id
|
||||||
ORDER BY c.name COLLATE NOCASE
|
ORDER BY c.name COLLATE NOCASE
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
|
|||||||
Reference in New Issue
Block a user