big update
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Package applicatif : contrôle du bot Twitch et état partagé pour l’interface web."""
|
||||
@@ -0,0 +1,3 @@
|
||||
"""État global minimal pour l’API web (envoi des messages dans le chat Twitch)."""
|
||||
|
||||
chat_messages_enabled = False
|
||||
@@ -0,0 +1,345 @@
|
||||
"""Contrôle centralisé des flux Twitch, bots et boucles IA / envoi chat."""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from fonction.first_class import (
|
||||
IA_generator,
|
||||
RecordTwitch,
|
||||
Subtitle_translation,
|
||||
TwitchChatBot,
|
||||
messageTwitch,
|
||||
storage,
|
||||
)
|
||||
|
||||
from twitch_bot import chat_state
|
||||
from twitch_bot.interaction_chat import InteractionChatProcessor
|
||||
|
||||
|
||||
def _resolve_user_index(pseudo: str) -> int:
|
||||
"""
|
||||
Retourne l'index d'un compte dans config/user.json (case-insensitive).
|
||||
Fallback: 0.
|
||||
"""
|
||||
try:
|
||||
with open("config/user.json", "r", encoding="utf-8") as f:
|
||||
users = json.load(f) or []
|
||||
pseudo_l = (pseudo or "").strip().lstrip("@").lower()
|
||||
for i, u in enumerate(users):
|
||||
p = (u or {}).get("tw_acc_pseudo")
|
||||
if isinstance(p, str) and p.strip().lower() == pseudo_l:
|
||||
return i
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
def _resolve_first_enabled_user_index() -> int:
|
||||
try:
|
||||
with open("config/user.json", "r", encoding="utf-8") as f:
|
||||
users = json.load(f) or []
|
||||
for i, u in enumerate(users):
|
||||
if isinstance(u, dict) and u.get("enabled", True):
|
||||
return i
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
class BotController:
|
||||
def __init__(self):
|
||||
self.bots = {} # Stockage des instances de bots (pour l'utilisation interne)
|
||||
self.flux_list = [] # Liste des flux surveillés (pour l'API JSON)
|
||||
self.config = self.load_config()
|
||||
self.ia_generator = None
|
||||
self.control_twitch = None
|
||||
self.ia_generator_running = False
|
||||
self.control_twitch_running = False
|
||||
|
||||
def load_config(self):
|
||||
try:
|
||||
with open('config/config.json', 'r') as file:
|
||||
return json.load(file)
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
|
||||
def save_config(self):
|
||||
with open('config/config.json', 'w') as file:
|
||||
json.dump(self.config, file, indent=4, ensure_ascii=False)
|
||||
|
||||
def get_system_status(self):
|
||||
"""Obtenir le statut de tous les composants"""
|
||||
return {
|
||||
'ia_generator': {
|
||||
'running': self.ia_generator_running,
|
||||
'status': 'En cours' if self.ia_generator_running else 'Arrêté'
|
||||
},
|
||||
'control_twitch': {
|
||||
'running': self.control_twitch_running,
|
||||
'status': 'En cours' if self.control_twitch_running else 'Arrêté'
|
||||
},
|
||||
'flux_count': len(self.flux_list),
|
||||
'active_flux': len([f for f in self.flux_list if f['active']])
|
||||
}
|
||||
|
||||
def add_flux(self, channel_name, record_audio=True):
|
||||
flux_id = len(self.flux_list) + 1
|
||||
|
||||
# Créer l'objet flux pour l'API (sans les instances de bots)
|
||||
flux_data = {
|
||||
'id': flux_id,
|
||||
'name': channel_name,
|
||||
'twitchname': channel_name,
|
||||
'record_audio': record_audio,
|
||||
'active': True,
|
||||
'created_at': datetime.now().isoformat(),
|
||||
'status': 'starting'
|
||||
}
|
||||
|
||||
try:
|
||||
# Créer le bot de chat pour ce flux
|
||||
chat_bot = TwitchChatBot(channel_name)
|
||||
|
||||
# Interaction chat (mentions -> réponses préenregistrées)
|
||||
def get_registered_accounts():
|
||||
try:
|
||||
with open("config/user.json", "r", encoding="utf-8") as f:
|
||||
users = json.load(f)
|
||||
pseudos = []
|
||||
for u in users or []:
|
||||
p = (u or {}).get("tw_acc_pseudo")
|
||||
enabled = (u or {}).get("enabled", True)
|
||||
if enabled and isinstance(p, str) and p.strip():
|
||||
pseudos.append(p.strip())
|
||||
return pseudos
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def get_account_policies():
|
||||
try:
|
||||
with open("config/user.json", "r", encoding="utf-8") as f:
|
||||
users = json.load(f) or []
|
||||
out = {}
|
||||
for u in users:
|
||||
if not isinstance(u, dict):
|
||||
continue
|
||||
p = (u.get("tw_acc_pseudo") or "").strip().lstrip("@").lower()
|
||||
if not p:
|
||||
continue
|
||||
out[p] = {
|
||||
"enabled": bool(u.get("enabled", True)),
|
||||
"interaction_bypass_antiloop": bool(u.get("interaction_bypass_antiloop", False)),
|
||||
}
|
||||
return out
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
msg_bot_for_interaction = messageTwitch("config/user.json", channel_name)
|
||||
interaction = InteractionChatProcessor(
|
||||
channel_name=channel_name,
|
||||
get_registered_accounts=get_registered_accounts,
|
||||
get_account_policies=get_account_policies,
|
||||
send_message_as=lambda pseudo, text: msg_bot_for_interaction.send_message_user(
|
||||
_resolve_user_index(pseudo), text
|
||||
),
|
||||
)
|
||||
|
||||
self.bots[flux_id] = {
|
||||
'chat_bot': chat_bot,
|
||||
'record_bot': None,
|
||||
'subtitle_bot': None,
|
||||
'ia_bot': None,
|
||||
'message_bot': None,
|
||||
'interaction': interaction,
|
||||
}
|
||||
chat_bot.start_background()
|
||||
|
||||
interaction.start_background(get_latest_messages=lambda: chat_bot.messages)
|
||||
|
||||
# Si enregistrement audio activé
|
||||
if record_audio:
|
||||
record_bot = RecordTwitch(channel_name, 60)
|
||||
self.bots[flux_id]['record_bot'] = record_bot
|
||||
threading.Thread(target=record_bot.main, daemon=True).start()
|
||||
|
||||
# Démarrer le bot de sous-titres
|
||||
subtitle_bot = Subtitle_translation("config/config.json")
|
||||
self.bots[flux_id]['subtitle_bot'] = subtitle_bot
|
||||
subtitle_bot.start_main_loop()
|
||||
|
||||
# Démarrer le générateur IA
|
||||
ia_bot = IA_generator("config/config.json")
|
||||
self.bots[flux_id]['ia_bot'] = ia_bot
|
||||
ia_bot.start_main_loop()
|
||||
|
||||
# Démarrer le contrôleur de messages
|
||||
message_bot = messageTwitch("config/user.json", channel_name)
|
||||
self.bots[flux_id]['message_bot'] = message_bot
|
||||
message_bot.start_loop_respond()
|
||||
|
||||
# Mettre à jour le statut
|
||||
flux_data['status'] = 'active'
|
||||
self.flux_list.append(flux_data)
|
||||
return flux_id
|
||||
|
||||
except Exception as e:
|
||||
print(f"Erreur lors de l'ajout du flux {channel_name}: {str(e)}")
|
||||
# Nettoyer en cas d'erreur
|
||||
if flux_id in self.bots:
|
||||
try:
|
||||
if self.bots[flux_id]['chat_bot']:
|
||||
self.bots[flux_id]['chat_bot'].stop()
|
||||
if self.bots[flux_id]['record_bot']:
|
||||
self.bots[flux_id]['record_bot'].stop()
|
||||
if self.bots[flux_id]['subtitle_bot']:
|
||||
self.bots[flux_id]['subtitle_bot'].stop()
|
||||
if self.bots[flux_id]['ia_bot']:
|
||||
self.bots[flux_id]['ia_bot'].stop()
|
||||
if self.bots[flux_id]['message_bot']:
|
||||
self.bots[flux_id]['message_bot'].stop()
|
||||
except Exception:
|
||||
pass
|
||||
del self.bots[flux_id]
|
||||
flux_data['status'] = 'error'
|
||||
flux_data['error'] = str(e)
|
||||
self.flux_list.append(flux_data)
|
||||
raise e
|
||||
|
||||
def remove_flux(self, flux_id):
|
||||
for i, flux in enumerate(self.flux_list):
|
||||
if flux['id'] == flux_id:
|
||||
# Arrêter les bots si ils existent
|
||||
if flux_id in self.bots:
|
||||
try:
|
||||
if self.bots[flux_id].get('interaction'):
|
||||
self.bots[flux_id]['interaction'].stop()
|
||||
if self.bots[flux_id]['chat_bot']:
|
||||
self.bots[flux_id]['chat_bot'].stop()
|
||||
if self.bots[flux_id]['record_bot']:
|
||||
self.bots[flux_id]['record_bot'].stop()
|
||||
if self.bots[flux_id]['subtitle_bot']:
|
||||
self.bots[flux_id]['subtitle_bot'].stop()
|
||||
if self.bots[flux_id]['ia_bot']:
|
||||
self.bots[flux_id]['ia_bot'].stop()
|
||||
if self.bots[flux_id]['message_bot']:
|
||||
self.bots[flux_id]['message_bot'].stop()
|
||||
except Exception as e:
|
||||
print(f"Erreur lors de l'arrêt des bots: {e}")
|
||||
del self.bots[flux_id]
|
||||
|
||||
del self.flux_list[i]
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_flux_list(self):
|
||||
# Retourner seulement les données JSON (pas les instances de bots)
|
||||
return self.flux_list
|
||||
|
||||
def start_ia_generator(self):
|
||||
"""Démarrer le générateur IA de manière contrôlée"""
|
||||
if self.ia_generator_running:
|
||||
return False, "IA Generator déjà en cours d'exécution"
|
||||
|
||||
try:
|
||||
self.ia_generator = IA_generator("config/config.json")
|
||||
self.ia_generator_running = True
|
||||
|
||||
# Activer l'envoi de messages quand l'IA Generator est démarré
|
||||
chat_state.chat_messages_enabled = True
|
||||
|
||||
# Démarrer dans un thread séparé
|
||||
threading.Thread(target=self._ia_generator_loop, daemon=True).start()
|
||||
|
||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] IA Generator démarré")
|
||||
return True, "IA Generator démarré avec succès"
|
||||
except Exception as e:
|
||||
self.ia_generator_running = False
|
||||
return False, f"Erreur lors du démarrage de l'IA Generator: {str(e)}"
|
||||
|
||||
def stop_ia_generator(self):
|
||||
"""Arrêter le générateur IA"""
|
||||
if not self.ia_generator_running:
|
||||
return False, "IA Generator n'est pas en cours d'exécution"
|
||||
|
||||
try:
|
||||
self.ia_generator_running = False
|
||||
if self.ia_generator:
|
||||
self.ia_generator.stop()
|
||||
|
||||
# Désactiver l'envoi de messages quand l'IA Generator est arrêté
|
||||
chat_state.chat_messages_enabled = False
|
||||
|
||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] IA Generator arrêté")
|
||||
return True, "IA Generator arrêté avec succès"
|
||||
except Exception as e:
|
||||
return False, f"Erreur lors de l'arrêt de l'IA Generator: {str(e)}"
|
||||
|
||||
def _ia_generator_loop(self):
|
||||
"""Boucle contrôlée pour l'IA Generator"""
|
||||
while self.ia_generator_running:
|
||||
try:
|
||||
if self.ia_generator:
|
||||
self.ia_generator.main_ask("") # Génération automatique
|
||||
time.sleep(20) # Attendre 20 secondes entre les générations
|
||||
except Exception as e:
|
||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] Erreur dans IA Generator: {e}")
|
||||
time.sleep(10)
|
||||
|
||||
def start_control_twitch(self):
|
||||
"""Démarrer le contrôleur Twitch de manière contrôlée"""
|
||||
if self.control_twitch_running:
|
||||
return False, "Control Twitch déjà en cours d'exécution"
|
||||
|
||||
try:
|
||||
# Utiliser le premier utilisateur par défaut
|
||||
self.control_twitch = messageTwitch("config/user.json", "default")
|
||||
self.control_twitch_running = True
|
||||
|
||||
# Démarrer dans un thread séparé
|
||||
threading.Thread(target=self._control_twitch_loop, daemon=True).start()
|
||||
|
||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] Control Twitch démarré")
|
||||
return True, "Control Twitch démarré avec succès"
|
||||
except Exception as e:
|
||||
self.control_twitch_running = False
|
||||
return False, f"Erreur lors du démarrage de Control Twitch: {str(e)}"
|
||||
|
||||
def stop_control_twitch(self):
|
||||
"""Arrêter le contrôleur Twitch"""
|
||||
if not self.control_twitch_running:
|
||||
return False, "Control Twitch n'est pas en cours d'exécution"
|
||||
|
||||
try:
|
||||
self.control_twitch_running = False
|
||||
if self.control_twitch:
|
||||
self.control_twitch.stop()
|
||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] Control Twitch arrêté")
|
||||
return True, "Control Twitch arrêté avec succès"
|
||||
except Exception as e:
|
||||
return False, f"Erreur lors de l'arrêt de Control Twitch: {str(e)}"
|
||||
|
||||
def _control_twitch_loop(self):
|
||||
"""Boucle contrôlée pour Control Twitch"""
|
||||
while self.control_twitch_running:
|
||||
try:
|
||||
if self.control_twitch:
|
||||
# Vérifier s'il y a des générations à envoyer
|
||||
generation_data = storage.read("IA_generator")
|
||||
if generation_data:
|
||||
sorted_keys = sorted(generation_data.keys())
|
||||
if sorted_keys:
|
||||
last_generation = generation_data[sorted_keys[-1]]
|
||||
# Envoyer le message avec le premier utilisateur activé
|
||||
self.control_twitch.send_message_user(_resolve_first_enabled_user_index(), last_generation)
|
||||
# Supprimer la génération envoyée
|
||||
storage.delete("IA_generator", sorted_keys[-1])
|
||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] Message envoyé: {last_generation[:50]}...")
|
||||
time.sleep(10) # Attendre 10 secondes entre les vérifications
|
||||
except Exception as e:
|
||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] Erreur dans Control Twitch: {e}")
|
||||
time.sleep(10)
|
||||
|
||||
|
||||
bot_controller = BotController()
|
||||
@@ -0,0 +1,487 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _lower(s: Optional[str]) -> Optional[str]:
|
||||
return s.lower() if isinstance(s, str) else None
|
||||
|
||||
|
||||
def _contains_case_insensitive(haystack: str, needle: str) -> bool:
|
||||
return needle.lower() in haystack.lower()
|
||||
|
||||
|
||||
def _normalize_accounts(accounts: List[str]) -> List[str]:
|
||||
out: List[str] = []
|
||||
for a in accounts:
|
||||
a = (a or "").strip()
|
||||
if not a:
|
||||
continue
|
||||
if a.startswith("@"):
|
||||
a = a[1:]
|
||||
out.append(a.lower())
|
||||
return sorted(set(out))
|
||||
|
||||
|
||||
def _extract_mentioned_accounts(message: str, registered_accounts: List[str]) -> List[str]:
|
||||
msg = message or ""
|
||||
hits: List[str] = []
|
||||
for acc in registered_accounts:
|
||||
if _contains_case_insensitive(msg, f"@{acc}"):
|
||||
hits.append(acc)
|
||||
return hits
|
||||
|
||||
|
||||
def _strip_registered_mentions(message: str, registered_accounts: List[str]) -> str:
|
||||
"""
|
||||
Retire du texte les mentions @<compte> pour les comptes enregistrés.
|
||||
Exemple: "@exoticnaturees hello" -> "hello"
|
||||
"""
|
||||
out = message or ""
|
||||
for acc in registered_accounts:
|
||||
# \b pour éviter de matcher des sous-chaînes
|
||||
out = re.sub(rf"@{re.escape(acc)}\b", "", out, flags=re.IGNORECASE)
|
||||
# Normaliser les espaces
|
||||
out = re.sub(r"\s+", " ", out).strip()
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class InteractionRule:
|
||||
id: str
|
||||
enabled: bool
|
||||
from_username: Optional[str] = None
|
||||
mention_account: Optional[str] = None
|
||||
contains_text: Optional[str] = None
|
||||
response_text: str = ""
|
||||
tgpt_preprompt: Optional[str] = None
|
||||
|
||||
@staticmethod
|
||||
def from_dict(d: Dict[str, Any]) -> "InteractionRule":
|
||||
return InteractionRule(
|
||||
id=str(d.get("id") or ""),
|
||||
enabled=bool(d.get("enabled", True)),
|
||||
from_username=d.get("from_username") or None,
|
||||
mention_account=d.get("mention_account") or None,
|
||||
contains_text=d.get("contains_text") or None,
|
||||
response_text=str(d.get("response_text") or ""),
|
||||
tgpt_preprompt=d.get("tgpt_preprompt") or None,
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"enabled": self.enabled,
|
||||
"from_username": self.from_username,
|
||||
"mention_account": self.mention_account,
|
||||
"contains_text": self.contains_text,
|
||||
"response_text": self.response_text,
|
||||
"tgpt_preprompt": self.tgpt_preprompt,
|
||||
}
|
||||
|
||||
def matches(self, *, username: str, message: str, mentioned_accounts: List[str]) -> bool:
|
||||
if not self.enabled:
|
||||
return False
|
||||
if self.from_username and _lower(self.from_username) != _lower(username):
|
||||
return False
|
||||
if self.mention_account:
|
||||
ma = self.mention_account.strip()
|
||||
if ma.startswith("@"):
|
||||
ma = ma[1:]
|
||||
if ma.lower() not in mentioned_accounts:
|
||||
return False
|
||||
if self.contains_text and not _contains_case_insensitive(message, self.contains_text):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class InteractionChatConfig:
|
||||
def __init__(self, data: Optional[Dict[str, Any]] = None):
|
||||
data = data or {}
|
||||
self.enabled: bool = bool(data.get("enabled", True))
|
||||
self.mode: str = str(data.get("mode", "predefined")) # predefined | tgpt (inactive by default)
|
||||
self.tgpt_enabled: bool = bool(data.get("tgpt_enabled", False))
|
||||
self.tgpt_preprompt: str = str(data.get("tgpt_preprompt", "") or "")
|
||||
self.tgpt_max_chars: int = int(data.get("tgpt_max_chars", 100))
|
||||
self.cooldown_seconds: int = int(data.get("cooldown_seconds", 8))
|
||||
self.default_responses: List[str] = [
|
||||
s.strip() for s in (data.get("default_responses") or ["salut"]) if isinstance(s, str) and s.strip()
|
||||
]
|
||||
self.rules: List[InteractionRule] = [
|
||||
InteractionRule.from_dict(x) for x in (data.get("rules") or []) if isinstance(x, dict)
|
||||
]
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"enabled": self.enabled,
|
||||
"mode": self.mode,
|
||||
"tgpt_enabled": self.tgpt_enabled,
|
||||
"tgpt_preprompt": self.tgpt_preprompt,
|
||||
"tgpt_max_chars": self.tgpt_max_chars,
|
||||
"cooldown_seconds": self.cooldown_seconds,
|
||||
"default_responses": self.default_responses,
|
||||
"rules": [r.to_dict() for r in self.rules],
|
||||
}
|
||||
|
||||
|
||||
class InteractionChatStorage:
|
||||
"""
|
||||
Stockage simple en JSON dans storage/.
|
||||
On évite d'utiliser PersistentStorage ici pour pouvoir stocker des listes (logs) proprement.
|
||||
"""
|
||||
|
||||
def __init__(self, storage_dir: str = "storage"):
|
||||
self.storage_dir = storage_dir
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _path(self, filename: str) -> str:
|
||||
if not filename.endswith(".json"):
|
||||
filename = f"{filename}.json"
|
||||
return os.path.join(self.storage_dir, filename)
|
||||
|
||||
def read_json(self, filename: str, default: Any) -> Any:
|
||||
path = self._path(filename)
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return default
|
||||
|
||||
def write_json(self, filename: str, value: Any) -> None:
|
||||
if not os.path.exists(self.storage_dir):
|
||||
os.makedirs(self.storage_dir, exist_ok=True)
|
||||
path = self._path(filename)
|
||||
with self._lock:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(value, f, indent=4, ensure_ascii=False)
|
||||
|
||||
|
||||
class InteractionChatProcessor:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
channel_name: str,
|
||||
get_registered_accounts: callable,
|
||||
get_account_policies: Optional[callable] = None,
|
||||
send_message_as: callable,
|
||||
storage: Optional[InteractionChatStorage] = None,
|
||||
):
|
||||
self.channel_name = channel_name
|
||||
self._get_registered_accounts = get_registered_accounts
|
||||
self._get_account_policies = get_account_policies
|
||||
self._send_message_as = send_message_as
|
||||
self._storage = storage or InteractionChatStorage()
|
||||
|
||||
self._config_file = "interaction_chat_config"
|
||||
self._log_file = "interaction_chat_log"
|
||||
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
self._last_seen_key: Optional[Tuple[str, str, str]] = None # (timestamp_iso, username, content)
|
||||
self._last_reply_ts_by_user: Dict[str, float] = {}
|
||||
|
||||
def load_config(self) -> InteractionChatConfig:
|
||||
data = self._storage.read_json(self._config_file, default={})
|
||||
return InteractionChatConfig(data)
|
||||
|
||||
def save_config(self, config_dict: Dict[str, Any]) -> InteractionChatConfig:
|
||||
cfg = InteractionChatConfig(config_dict)
|
||||
self._storage.write_json(self._config_file, cfg.to_dict())
|
||||
return cfg
|
||||
|
||||
def append_log(self, entry: Dict[str, Any]) -> None:
|
||||
logs = self._storage.read_json(self._log_file, default=[])
|
||||
if not isinstance(logs, list):
|
||||
logs = []
|
||||
logs.append(entry)
|
||||
logs = logs[-300:] # borne simple
|
||||
self._storage.write_json(self._log_file, logs)
|
||||
|
||||
def read_log(self, limit: int = 100) -> List[Dict[str, Any]]:
|
||||
logs = self._storage.read_json(self._log_file, default=[])
|
||||
if not isinstance(logs, list):
|
||||
return []
|
||||
return logs[-limit:]
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=2)
|
||||
|
||||
def start_background(self, *, get_latest_messages: callable, poll_interval_s: float = 0.5) -> None:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
|
||||
self._running = True
|
||||
|
||||
def loop() -> None:
|
||||
while self._running:
|
||||
try:
|
||||
self.process_new_messages(get_latest_messages())
|
||||
except Exception as e:
|
||||
self.append_log(
|
||||
{
|
||||
"ts": _now_iso(),
|
||||
"type": "error",
|
||||
"channel": self.channel_name,
|
||||
"error": str(e),
|
||||
}
|
||||
)
|
||||
time.sleep(poll_interval_s)
|
||||
|
||||
self._thread = threading.Thread(target=loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _cooldown_ok(self, username: str, cooldown_s: int) -> bool:
|
||||
last = self._last_reply_ts_by_user.get(username.lower())
|
||||
if last is None:
|
||||
return True
|
||||
return (time.time() - last) >= max(0, cooldown_s)
|
||||
|
||||
def _mark_replied(self, username: str) -> None:
|
||||
self._last_reply_ts_by_user[username.lower()] = time.time()
|
||||
|
||||
def process_new_messages(self, messages: List[Any]) -> None:
|
||||
"""
|
||||
`messages` est typiquement une liste de ChatMessage (timestamp, username, content).
|
||||
On traite uniquement les nouveaux messages depuis le dernier message vu.
|
||||
"""
|
||||
if not messages:
|
||||
return
|
||||
|
||||
cfg = self.load_config()
|
||||
if not cfg.enabled:
|
||||
return
|
||||
|
||||
registered_accounts = _normalize_accounts(self._get_registered_accounts())
|
||||
if not registered_accounts:
|
||||
return
|
||||
|
||||
# On ne veut pas répondre aux comptes "enregistrés" (évite boucles)
|
||||
registered_set = set(registered_accounts)
|
||||
policies: Dict[str, Dict[str, Any]] = {}
|
||||
try:
|
||||
if self._get_account_policies:
|
||||
policies = self._get_account_policies() or {}
|
||||
except Exception:
|
||||
policies = {}
|
||||
|
||||
# Construire une vue stable des derniers messages (sécurité)
|
||||
tail = messages[-80:]
|
||||
to_process: List[Any] = []
|
||||
|
||||
if self._last_seen_key is None:
|
||||
# premier passage: ne répondre qu'aux prochains messages, pas au backlog
|
||||
last = tail[-1]
|
||||
self._last_seen_key = (getattr(last, "timestamp").isoformat(), getattr(last, "username"), getattr(last, "content"))
|
||||
return
|
||||
|
||||
seen = False
|
||||
for m in tail:
|
||||
key = (getattr(m, "timestamp").isoformat(), getattr(m, "username"), getattr(m, "content"))
|
||||
if seen:
|
||||
to_process.append(m)
|
||||
elif key == self._last_seen_key:
|
||||
seen = True
|
||||
|
||||
if not to_process:
|
||||
# Si la clé a disparu (tail trop court), on se recale sur le dernier message
|
||||
last = tail[-1]
|
||||
self._last_seen_key = (getattr(last, "timestamp").isoformat(), getattr(last, "username"), getattr(last, "content"))
|
||||
return
|
||||
|
||||
for m in to_process:
|
||||
ts_iso = getattr(m, "timestamp").isoformat()
|
||||
username = str(getattr(m, "username") or "")
|
||||
content = str(getattr(m, "content") or "")
|
||||
|
||||
# mise à jour last_seen à chaque itération
|
||||
self._last_seen_key = (ts_iso, username, content)
|
||||
|
||||
if not username or not content:
|
||||
continue
|
||||
|
||||
sender = username.strip().lower()
|
||||
if sender in registered_set:
|
||||
allow_bypass = bool((policies.get(sender) or {}).get("interaction_bypass_antiloop", False))
|
||||
if not allow_bypass:
|
||||
continue
|
||||
|
||||
mentioned = _extract_mentioned_accounts(content, registered_accounts)
|
||||
if not mentioned:
|
||||
continue
|
||||
|
||||
if not self._cooldown_ok(username, cfg.cooldown_seconds):
|
||||
continue
|
||||
|
||||
# mode tgpt prévu mais inactif par défaut
|
||||
response_text = ""
|
||||
matched_rule_id: Optional[str] = None
|
||||
responder_account: Optional[str] = None
|
||||
rule_tgpt_preprompt: Optional[str] = None
|
||||
|
||||
for r in cfg.rules:
|
||||
if r.matches(username=username, message=content, mentioned_accounts=mentioned):
|
||||
response_text = (r.response_text or "").strip()
|
||||
matched_rule_id = r.id
|
||||
if r.mention_account:
|
||||
ma = r.mention_account.strip()
|
||||
if ma.startswith("@"):
|
||||
ma = ma[1:]
|
||||
responder_account = ma.lower() if ma else None
|
||||
if r.tgpt_preprompt:
|
||||
rule_tgpt_preprompt = str(r.tgpt_preprompt)
|
||||
break
|
||||
|
||||
# Choisir le compte expéditeur
|
||||
if mentioned and not responder_account:
|
||||
responder_account = mentioned[0].lower()
|
||||
|
||||
# Priorité aux règles: si une règle match, on n'exécute QUE la règle.
|
||||
if matched_rule_id is not None:
|
||||
if not response_text:
|
||||
self.append_log(
|
||||
{
|
||||
"ts": _now_iso(),
|
||||
"type": "rule_empty_response",
|
||||
"channel": self.channel_name,
|
||||
"from": username,
|
||||
"content": content,
|
||||
"mentioned_accounts": mentioned,
|
||||
"responder_account": responder_account,
|
||||
"rule_id": matched_rule_id,
|
||||
}
|
||||
)
|
||||
continue
|
||||
else:
|
||||
if cfg.mode == "tgpt" and cfg.tgpt_enabled:
|
||||
preprompt = (rule_tgpt_preprompt or cfg.tgpt_preprompt or "").strip()
|
||||
cleaned_for_tgpt = _strip_registered_mentions(content, registered_accounts)
|
||||
response_text = self._tgpt_generate(preprompt=preprompt, message=cleaned_for_tgpt)
|
||||
response_text = self._truncate(response_text, cfg.tgpt_max_chars)
|
||||
if not response_text:
|
||||
self.append_log(
|
||||
{
|
||||
"ts": _now_iso(),
|
||||
"type": "tgpt_empty",
|
||||
"channel": self.channel_name,
|
||||
"from": username,
|
||||
"content": content,
|
||||
"content_sent_to_tgpt": cleaned_for_tgpt,
|
||||
"responder_account": responder_account,
|
||||
}
|
||||
)
|
||||
continue
|
||||
else:
|
||||
if not response_text and cfg.default_responses:
|
||||
response_text = cfg.default_responses[0].strip()
|
||||
if not response_text:
|
||||
continue
|
||||
|
||||
outgoing = f"@{username} {response_text}".strip()
|
||||
# fallback: si on ne sait pas quel compte utiliser, on prend le 1er enregistré
|
||||
if not responder_account:
|
||||
responder_account = registered_accounts[0].lower()
|
||||
|
||||
self._send_message_as(responder_account, outgoing)
|
||||
self._mark_replied(username)
|
||||
|
||||
self.append_log(
|
||||
{
|
||||
"ts": _now_iso(),
|
||||
"type": "responded",
|
||||
"channel": self.channel_name,
|
||||
"from": username,
|
||||
"content": content,
|
||||
"mentioned_accounts": mentioned,
|
||||
"responder_account": responder_account,
|
||||
"response": outgoing,
|
||||
"rule_id": matched_rule_id,
|
||||
"mode": cfg.mode,
|
||||
}
|
||||
)
|
||||
|
||||
def _truncate(self, text: str, max_chars: int) -> str:
|
||||
t = (text or "").strip()
|
||||
if max_chars is None:
|
||||
return t
|
||||
try:
|
||||
n = int(max_chars)
|
||||
except Exception:
|
||||
n = 100
|
||||
if n <= 0:
|
||||
return ""
|
||||
if len(t) <= n:
|
||||
return t
|
||||
# laisser de la place pour "..."
|
||||
cut = max(0, n - 3)
|
||||
return t[:cut].rstrip() + "..."
|
||||
|
||||
def _tgpt_generate(self, *, preprompt: str, message: str) -> str:
|
||||
"""
|
||||
Appelle tgpt (CLI) et retourne une réponse nettoyée.
|
||||
"""
|
||||
query = message.strip()
|
||||
if preprompt:
|
||||
query = f"{preprompt}\n\n{query}"
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["tgpt", "-q", query],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=25,
|
||||
check=False,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
self.append_log(
|
||||
{
|
||||
"ts": _now_iso(),
|
||||
"type": "tgpt_error",
|
||||
"channel": self.channel_name,
|
||||
"error": "tgpt_not_found",
|
||||
}
|
||||
)
|
||||
return ""
|
||||
except subprocess.TimeoutExpired:
|
||||
self.append_log(
|
||||
{
|
||||
"ts": _now_iso(),
|
||||
"type": "tgpt_error",
|
||||
"channel": self.channel_name,
|
||||
"error": "tgpt_timeout",
|
||||
}
|
||||
)
|
||||
return ""
|
||||
except Exception as e:
|
||||
self.append_log(
|
||||
{
|
||||
"ts": _now_iso(),
|
||||
"type": "tgpt_error",
|
||||
"channel": self.channel_name,
|
||||
"error": str(e),
|
||||
}
|
||||
)
|
||||
return ""
|
||||
|
||||
out = (proc.stdout or "").strip()
|
||||
if not out:
|
||||
out = (proc.stderr or "").strip()
|
||||
|
||||
# Nettoyage minimal (tgpt renvoie parfois "Assistant:" / "Answer:")
|
||||
for prefix in ("Assistant:", "assistant:", "Answer:", "Réponse:", "Response:"):
|
||||
if out.startswith(prefix):
|
||||
out = out[len(prefix) :].strip()
|
||||
return out.replace("\n", " ").strip()
|
||||
|
||||
Reference in New Issue
Block a user