"""lg-rez / features / IA des réponses
Tout ce qui concerne la manière dont le bot réagit aux messages :
détermination de la meilleure réaction, gestion des réactions,
activation/désactivation des modes de chat
"""
import re
import random
import requests
from discord.ext import commands
from lgrez import config
from lgrez.blocs import tools
from lgrez.bdd import Trigger, Reaction, Role
# Marqueurs de séparation du mini-langage des séquences-réactions
MARK_OR = ' <||> '
MARK_THEN = ' <&&> '
MARK_REACT = '<::>'
MARK_CMD = '<!!>'
MARKS = [MARK_OR, MARK_THEN, MARK_REACT, MARK_CMD]
async def _build_sequence(ctx):
"""Construction d'une séquence-réaction par l'utilisateur"""
reponse = ""
fini = False
while not fini:
message = await ctx.send(
"Réaction du bot : prochain message/commande/média, "
"ou réaction à ce message"
)
ret = await tools.wait_for_react_clic(
message, process_text=True, trigger_all_reacts=True,
trigger_on_commands=True
)
if isinstance(ret, str):
if ret.startswith(config.bot.command_prefix): # Commande
reponse += MARK_CMD + ret.lstrip(config.bot.command_prefix)
else: # Texte / média
reponse += ret
else: # React
reponse += MARK_REACT + ret.name
message = await ctx.send("▶ Puis / 🔀 Ou / ⏹ Fin ?")
ret = await tools.wait_for_react_clic(
message, emojis={"▶": MARK_THEN, "🔀": MARK_OR, "⏹": False}
)
if ret:
# On ajoute la marque OR ou THEN à la séquence
reponse += ret
else:
fini = True
return reponse
[docs]def fetch_tenor(trigger):
"""Renvoie le GIF Tenor le plus "pertinent" pour un texte donné.
Args:
trigger (str): texte auquel réagir.
Returns:
:class:`str` (URL du GIF) ou ``None``
"""
# API key module ternorpy (parce que la flemme de créer un compte Tenor)
apikey = "J5UVWPVIM4A5"
rep = requests.get(
url="https://api.tenor.com/v1/search",
params={
"q": trigger, "key": apikey, "limit": 1,
"locale": "fr_FR", "contentfilter": "off",
"media_filter": "minimal", "ar_range": "all"
}
)
if rep:
gifs = rep.json()["results"]
# Payload Tenor : {..., "results":[ ... ]}
# (https://tenor.com/gifapi/documentation#responseobjects-gif)
if gifs:
return gifs[0]["itemurl"]
return None # Pas de GIF trouvé
[docs]class GestionIA(commands.Cog):
"""Commandes relatives à l'IA (réponses automatiques du bot)"""
@commands.command()
@tools.private
async def stfu(self, ctx, force=None):
"""Active/désactive la réponse automatique du bot sur ton channel privé
Args:
force: ``"start"``/``"on"`` / ``"stop"``/``"off"`` permet de
forcer l'activation / la désactivation.
Sans argument, la commande active les réactions si désactivées
et vice-versa ; avec un autre argument, elle le fait silencieusment.
N'agit que sur les messages classiques envoyés dans le channel :
les commandes restent reconnues.
Si vous ne comprenez pas le nom de la commande, demandez à Google.
"""
id = ctx.channel.id
if force in [None, "start", "on"] and id not in config.bot.in_stfu:
config.bot.in_stfu.append(id)
await ctx.send(
"Okay, je me tais ! Tape !stfu quand tu voudras de "
"nouveau de moi :cry:"
)
elif force in [None, "stop", "off"] and id in config.bot.in_stfu:
config.bot.in_stfu.remove(id)
await ctx.send("Ahhh, ça fait plaisir de pouvoir reparler !")
elif force not in ["start", "on", "stop", "off"]:
# Quelque chose d'autre que start/stop précisé après !stfu :
# bot discret
if id in config.bot.in_stfu:
config.bot.in_stfu.remove(id)
else:
config.bot.in_stfu.append(id)
@commands.command(aliases=["cancer", "214"])
async def fals(self, ctx, force=None):
"""Active/désactive le mode « foire à la saucisse »
Args:
force: ``"start"``/``"on"`` / ``"stop"``/``"off"`` permet de
forcer l'activation / la désactivation.
Sans argument, la commande active le mode si désactivé
et vice-versa.
En mode « foire à la saucisse », le bot réagira à (presque) tous
les messages, pas seulement sur les motifs qu'on lui a appris.
À utiliser à vos risques et périls !
"""
id = ctx.channel.id
if force in [None, "start", "on"] and id not in config.bot.in_fals:
config.bot.in_fals.append(id)
await ctx.send(
"https://tenor.com/view/saucisse-sausage-gif-5426973"
)
elif force in [None, "stop", "off"] and id in config.bot.in_fals:
config.bot.in_fals.remove(id)
await ctx.send("T'as raison, faut pas abuser des bonnes choses")
elif force not in ["start", "on", "stop", "off"]:
# Quelque chose d'autre que start/stop précisé après !fals :
# bot discret
if id in config.bot.in_fals:
config.bot.in_fals.remove(id)
else:
config.bot.in_fals.append(id)
@commands.command(aliases=["r"])
async def react(self, ctx, *, trigger):
"""Force le bot à réagir à un message
Args:
trigger: texte auquel le bot doit réagir
Permet de faire appel à l'IA du bot même sur les chans publics,
ou en mode STFU, etc.
Si utilisée par un MJ, active aussi le mode débug des
évaluations Python (messages d'erreur).
"""
oc = ctx.message.content
ctx.message.content = trigger
debug = (ctx.message.webhook_id
or ctx.author.top_role == config.Role.mj)
await process_IA(ctx.message, debug=debug)
ctx.message.content = oc
# On rétablit le message original pour ne pas qu'il trigger
# l'IA 2 fois, le cas échéant
@commands.command(aliases=["rf"])
async def reactfals(self, ctx, *, trigger):
"""Force le bot à réagir à un message comme en mode Foire à la saucisse
Args:
trigger: texte auquel le bot doit réagir
Permet de faire appel directement au mode Foire à la saucisse,
même si il n'est pas activé / sur un chan public.
"""
async with ctx.typing():
gif = fetch_tenor(trigger)
if gif:
await ctx.send(gif)
else:
await ctx.send("Palaref")
@commands.command()
@tools.mjs_et_redacteurs
async def addIA(self, ctx, *, triggers=None):
"""Ajoute une règle d'IA (COMMANDE MJ/RÉDACTEURS)
Args:
triggers: mot(s), phrase(s), ou expression(s) séparées par
des points-virgules ou sauts de lignes (ne peut pas
contenir de ">" ou ";" hors séparateurs)
Nouveau système d'ajout rapide :
!addIA trigger > reponse
!addIA trig1 ; trig2 > reponse
ou, si réponse à un message de contenu "trigger" :
!addIA > reponse (triggers = [trigger])
!addIA trig2 > reponse (triggers = [trigger, trig2])
Une sécurité empêche d'ajouter un trigger déjà existant.
Dans le cas où plusieurs expressions sont spécifiées, toutes
déclencheront l'action demandée.
"""
fast = False
if not triggers:
await ctx.send(
"Mots/expressions déclencheurs (non sensibles à la "
"casse / accents), séparés par des points-virgules "
"ou des sauts de ligne :"
)
mess = await tools.wait_for_message_here(ctx)
triggers = mess.content
elif ">" in triggers:
fast = True
# Mode rapide
triggers, reponse = triggers.split(">", maxsplit=1)
triggers = triggers.replace('\n', ';').split(';')
if fast and ctx.message.reference:
repmess = ctx.message.reference.resolved
if repmess:
triggers.append(repmess.content)
triggers = [tools.remove_accents(s).lower().strip() for s in triggers]
triggers = list({trig for trig in triggers if trig})
# filtre doublons (accents et non accents...) et triggers vides
for trigger in triggers.copy():
if Trigger.query.filter_by(trigger=trigger).all():
await ctx.send(f"Trigger `{trigger}` déjà associé à une "
"réaction, enlevé")
triggers.remove(trigger)
if not triggers:
await ctx.send("Aucun trigger valide, abort")
return
await ctx.send(f"Triggers : `{'` – `'.join(triggers)}`")
if fast:
reponse = reponse.strip()
else:
reponse = await _build_sequence(ctx)
await ctx.send(f"Résumé de la séquence : {tools.code(reponse)}")
async with ctx.typing():
reac = Reaction(reponse=reponse)
config.session.add(reac)
trigs = [Trigger(trigger=trigger, reaction=reac)
for trigger in triggers]
config.session.add_all(trigs)
config.session.commit()
await ctx.send("Règle ajoutée en base.")
@commands.command()
@tools.mjs_et_redacteurs
async def listIA(self, ctx, trigger=None, sensi=0.5):
"""Liste les règles d'IA reconnues par le bot (COMMANDE MJ/RÉDACTEURS)
Args
trigger (optionnel): mot/expression permettant de filter et
trier les résultats. SI ``trigger`` FAIT PLUS D'UN MOT,
IL DOIT ÊTRE ENTOURÉ PAR DES GUILLEMETS !
sensi: sensibilité de détection (ratio des caractères
correspondants, entre 0 et 1) si trigger est précisé.
"""
async with ctx.typing():
if trigger:
trigs = Trigger.find_nearest(
trigger, col=Trigger.trigger,
sensi=sensi, solo_si_parfait=False
)
if not trigs:
await ctx.send(
f"Rien trouvé, pas de chance (sensi = {sensi})"
)
return
else:
raw_trigs = Trigger.query.order_by(Trigger.id).all()
# Trié par date de création
trigs = list(zip(raw_trigs, [None] * len(raw_trigs)))
# Mise au format (trig, score)
reacts = [] # Réactions associées à notre liste de triggers
for trig in trigs:
if (reac := trig[0].reaction) not in reacts:
# Pas de doublons, et reste ordonné
reacts.append(reac)
def nettoy(s):
# Abrège la réponse si trop longue et neutralise les
# sauts de ligne / rupture code_bloc, pour affichage
s = s.replace('\r\n', '\\n').replace('\n', '\\n')
s = s.replace('\r', '\\r').replace("```", "'''")
if len(s) < 75:
return s
else:
return s[:50] + " [...] " + s[-15:]
rep = ""
for reac in reacts: # pour chaque réponse
r = ""
for (trig, score) in trigs: # pour chaque trigger
if trig.reaction == reac:
sc = f"({float(score):.2}) " if score else ""
r += f" - {sc}{trig.trigger}"
# (score) trigger - (score) trigger ...
rep += r.ljust(50) + f" ⇒ {nettoy(reac.reponse)}\n"
# ⇒ réponse
rep += "\nPour modifier une réaction, utiliser !modifIA <trigger>."
await tools.send_code_blocs(ctx, rep)
# On envoie, en séparant en blocs de 2000 caractères max
@commands.command()
@tools.mjs_et_redacteurs
async def modifIA(self, ctx, *, trigger=None):
"""Modifie/supprime une règle d'IA (COMMANDE MJ/RÉDACTEURS)
Args:
trigger: mot/expression déclenchant la réaction à
modifier/supprimer
Permet d'ajouter et supprimer des triggers, de modifier la
réaction du bot (construction d'une séquence de réponses
successives ou aléatoires) ou de supprimer la réaction.
"""
if not trigger:
await ctx.send(
"Mot/expression déclencheur de la réaction à modifier :"
)
mess = await tools.wait_for_message_here(ctx)
trigger = mess.content
trigs = Trigger.find_nearest(trigger, col=Trigger.trigger)
if not trigs:
await ctx.send("Rien trouvé.")
return
trig = trigs[0][0]
reac = trig.reaction
displ_seq = (reac.reponse if reac.reponse.startswith('`')
else tools.code(reac.reponse)) # Pour affichage
trigs = list(reac.triggers)
await ctx.send(
f"Triggers : `{'` – `'.join([trig.trigger for trig in trigs])}`\n"
f"Séquence réponse : {displ_seq}"
)
message = await ctx.send(
"Modifier : ⏩ triggers / ⏺ Réponse / 🚮 Supprimer ?"
)
choix = await tools.wait_for_react_clic(
message, emojis={"⏩": 1, "⏺": 2, "🚮": 0})
if choix == 1: # Modification des triggers
fini = False
while not fini:
s = "Supprimer un trigger : \n"
for i, t in enumerate(trigs[:10]):
s += f"{tools.emoji_chiffre(i+1)}. {t.trigger} \n"
mess = await ctx.send(
s + "Ou entrer un mot / une expression pour l'ajouter "
"en trigger.\n⏹ pour finir"
)
r = await tools.wait_for_react_clic(
mess,
emojis={(tools.emoji_chiffre(i) if i else "⏹"): str(i)
for i in range(len(trigs) + 1)},
process_text=True
)
if r == "0":
fini = True
elif r.isdigit() and (n := int(r)) <= len(trigs):
config.session.delete(trigs[n - 1])
config.session.commit()
del trigs[n - 1]
else:
new_trig = Trigger(trigger=r, reaction=reac)
trigs.append(new_trig)
config.session.add(trig)
config.session.commit()
if not trigs: # on a tout supprimé !
await ctx.send(
"Tous les triggers supprimés, suppression de la réaction"
)
config.session.delete(reac)
config.session.commit()
return
elif choix == 2: # Modification de la réponse
if any([mark in reac.reponse for mark in MARKS]):
# Séquence compliquée
await ctx.send(
"\nLa séquence-réponse peut être refaite manuellement "
"ou modifiée rapidement en envoyant directment la "
"séquence ci-dessus modifiée (avec les marqueurs : "
f"OU = {tools.code(MARK_OR)}, "
f"ET = {tools.code(MARK_THEN)}, "
f"REACT = {tools.code(MARK_REACT)}, "
f"CMD = {tools.code(MARK_CMD)})"
)
reponse = await _build_sequence(ctx)
reac.reponse = reponse
else: # Suppression
config.session.delete(reac)
for trig in trigs:
config.session.delete(trig)
config.session.commit()
await ctx.send("Fini.")
[docs]async def trigger_at_mj(message):
"""Règle d'IA : réaction si le message mentionne les MJs.
Args:
message (~discord.Message): message auquel réagir.
Returns:
- ``True`` -- si le message mentionne les MJ et qu'une réponse
a été envoyée
- ``False`` -- sinon
"""
if config.Role.mj in message.role_mentions:
await message.channel.send(
"Les MJs ont entenu ton appel, ils sont en route ! :superhero:"
)
return True
return False
[docs]async def trigger_roles(message, sensi=0.8):
"""Règle d'IA : réaction si un nom de rôle est donné.
Args:
message (~discord.Message): message auquel réagir.
sensi (float): sensibilité de la recherche
(voir :meth:`.bdd.base.TableMeta.find_nearest`).
Trouve l'entrée la plus proche de ``message.content`` dans la table
:class:`.bdd.Role`.
Returns:
- ``True`` -- si un rôle a été trouvé (sensibilité ``> sensi``)
et qu'une réponse a été envoyée
- ``False`` -- sinon
"""
roles = Role.find_nearest(message.content, col=Role.nom,
filtre=(Role.actif.is_(True)), sensi=sensi)
if roles: # Au moins un trigger trouvé à cette sensi
await message.channel.send(embed=roles[0][0].embed)
return True
return False
[docs]async def trigger_reactions(message, chain=None, sensi=0.7, debug=False):
"""Règle d'IA : réaction à partir de la table :class:`.bdd.Reaction`.
Args:
message (~discord.Message`): message auquel réagir.
chain (str): contenu auquel réagir (défaut : contenu de
``message``).
sensi (float): sensibilité de la recherche (cf
:meth:`.bdd.base.TableMeta.find_nearest`).
debug (bool): si ``True``, affiche les erreurs lors de
l'évaluation des messages (voir :func:`.tools.eval_accols`).
Trouve l'entrée la plus proche de ``chain`` dans la table
:class:`.bdd.Reaction` ; si il contient des accolades, évalue le
message selon le contexte de ``message``.
Returns:
- ``True`` -- si une réaction a été trouvé (sensibilité
``> sensi``) et qu'une réponse a été envoyée
- ``False`` -- sinon
"""
if not chain: # Si pas précisé,
chain = message.content # contenu de message
trigs = Trigger.find_nearest(chain, col=Trigger.trigger, sensi=sensi)
if trigs: # Au moins un trigger trouvé à cette sensi
trig = trigs[0][0] # Meilleur trigger (score max)
seq = trig.reaction.reponse # Séquence-réponse associée
for rep in seq.split(MARK_THEN): # Pour chaque étape :
if MARK_OR in rep:
# Si plusieurs possiblités, on en choisit une random
rep = random.choice(rep.split(MARK_OR))
if rep.startswith(MARK_REACT): # Réaction
react = rep.lstrip(MARK_REACT)
emoji = tools.emoji(react, must_be_found=False) or react
await message.add_reaction(emoji)
elif rep.startswith(MARK_CMD): # Commande
message.content = rep.replace(MARK_CMD,
config.bot.command_prefix)
# Exécution de la commande
await config.bot.process_commands(message)
else: # Sinon, texte / média
# On remplace tous les "{expr}" par leur évaluation
rep = tools.eval_accols(rep, locals_=locals(), debug=debug)
await message.channel.send(rep)
return True
return False
[docs]async def trigger_sub_reactions(message, sensi=0.9, debug=False):
"""Règle d'IA : réaction à partir de la table, mais sur les mots
Appelle :func:`trigger_reactions(bot, message, mot, sensi, debug)
<.trigger_reactions>` pour tous les mots ``mot`` composant
``message.content`` (mots de plus de 4 lettres, testés des plus
longs aux plus courts).
Args:
message (~discord.Message`): message auquel réagir.
sensi (float): sensibilité de la recherche (cf
:meth:`.bdd.base.TableMeta.find_nearest`).
debug (bool): si ``True``, affiche les erreurs lors de
l'évaluation des messages (voir :func:`.tools.eval_accols`).
Returns:
- ``True`` -- si une réaction a été trouvé (sensibilité
``> sensi``) et qu'une réponse a été envoyée
- ``False`` -- sinon
"""
mots = message.content.split(" ")
if len(mots) > 1: # Si le message fait plus d'un mot
for mot in sorted(mots, key=lambda m: -len(m)):
# On parcourt les mots du plus long au plus court
if len(mot) > 4: # on élimine les mots de liaison
if await trigger_reactions(message, chain=mot,
sensi=sensi, debug=debug):
# Si on trouve une sous-rect (à 0.9)
return True
return False
[docs]async def trigger_di(message):
"""Règle d'IA : réaction aux messages en di... / cri...
Args:
message (~discord.Message): message auquel réagir.
Returns:
- ``True`` -- si le message correspond et qu'une réponse a été
envoyée
- ``False`` -- sinon
"""
c = message.content
diprefs = ["di", "dy", "dis ", "dit ", "dis-", "dit-"]
criprefs = ["cri", "cry", "kri", "kry"]
pos_prefs = {c.lower().find(pref): pref for pref in diprefs + criprefs
if pref in c[:-1].lower()}
# On extrait les cas où le préfixe est à la fin du message
if pos_prefs: # Si on a trouvé au moins un préfixe
i = min(pos_prefs)
pref = pos_prefs[i]
if pref in criprefs:
mess = tools.bold(c[i + len(pref):].upper())
else:
mess = c[i + len(pref):]
await message.channel.send(mess, tts=True)
# On envoie le di.../cri... en mode TTS (oh si, c'est rigolo)
return True
return False
[docs]async def trigger_gif(message):
"""Règle d'IA : réaction par GIF en mode Foire à la saucisse.
Args:
message (~discord.Message): message auquel réagir
Returns:
- ``True`` -- si le message correspond et qu'une réponse a été
envoyée
- ``False`` -- sinon
"""
if message.channel.id in config.bot.in_fals:
# Chan en mode Foire à la saucisse
async with message.channel.typing():
gif = fetch_tenor(message.content)
if gif:
await message.channel.send(gif)
return True
return False
[docs]async def trigger_mot_unique(message):
"""Règle d'IA : réaction à un mot unique (le répète).
Args:
message (~discord.Message): message auquel réagir.
Returns:
- ``True`` -- si le message correspond et qu'une réponse a été
envoyée
- ``False`` -- sinon
"""
if len(message.content.split()) == 1 and ":" not in message.content:
# : pour ne pas trigger aux liens
rep = f"{message.content.capitalize()} ?"
await message.channel.send(rep)
return True
return False
[docs]async def trigger_a_ou_b(message):
"""Règle d'IA : réaction à un motif type « a ou b » (répond « b »).
Args:
message (~discord.Message): message auquel réagir.
Returns:
- ``True`` -- si le message correspond et qu'une réponse a été
envoyée
- ``False`` -- sinon
"""
if (motif := re.fullmatch(r"(.+)\s+ou\s+(.+?)", message.content)):
rep = f"{motif.group(2).rstrip(' !?.,;')}.".capitalize()
await message.channel.send(rep)
return True
return False
[docs]async def default(message):
"""Règle d'IA : réponse par défaut
Args:
message (~discord.Message): message auquel réagir.
Returns:
- ``True`` -- si le message correspond et qu'une réponse a été
envoyée
- ``False`` -- sinon
"""
mess = "Désolé, je n'ai pas compris :person_shrugging:"
if random.random() < 0.05:
mess += "\n(et toi, tu as perdu)"
await message.channel.send(mess)
return True
[docs]async def process_IA(message, debug=False):
"""Exécute les règles d'IA.
Args:
message (~discord.Message): message auquel réagir.
debug (bool): si ``True``, affiche les erreurs lors de
l'évaluation des messages (voir :func:`.tools.eval_accols`).
"""
(
await trigger_at_mj(message) # @MJ (aled)
or await trigger_gif(message) # Un petit GIF ? (si FALS)
or await trigger_roles(message) # Rôles
or await trigger_reactions( # Table Reaction ("IA")
message, debug=debug)
or await trigger_sub_reactions( # IA sur les mots
message, debug=debug)
or await trigger_a_ou_b(message) # "a ou b" ==> "b"
or await trigger_di(message) # di... / cri...
or await trigger_mot_unique(message) # Un seul mot ==> on répète
or await default(message) # Réponse par défaut
)