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 @ 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 action: str = "response" # response | tgpt | clip response_text: str = "" tgpt_preprompt: Optional[str] = None clip_has_delay: bool = False @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, action=str(d.get("action") or d.get("mode") or "response"), response_text=str(d.get("response_text") or ""), tgpt_preprompt=d.get("tgpt_preprompt") or None, clip_has_delay=bool(d.get("clip_has_delay", False)), ) 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, "action": self.action, "response_text": self.response_text, "tgpt_preprompt": self.tgpt_preprompt, "clip_has_delay": self.clip_has_delay, } 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)) # Mode principal: predefined (réponses) ou tgpt (IA) self.mode: str = str(data.get("mode", "predefined")) # predefined | tgpt # Backward compat: si tgpt_enabled est fourni, on le garde, sinon on déduit du mode. self.tgpt_enabled: bool = bool(data.get("tgpt_enabled", self.mode == "tgpt")) 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, create_clip_as: Optional[callable] = None, 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._create_clip_as = create_clip_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 rule_action: Optional[str] = None rule_clip_has_delay: bool = False 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 rule_action = (r.action or "response").strip().lower() 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) rule_clip_has_delay = bool(getattr(r, "clip_has_delay", False)) 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: action = (rule_action or "response").strip().lower() if action == "tgpt": 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, "rule_id": matched_rule_id, "mode": "rule_tgpt", } ) continue elif action == "clip": if not responder_account: responder_account = mentioned[0].lower() if not self._create_clip_as: self.append_log( { "ts": _now_iso(), "type": "clip_error", "channel": self.channel_name, "from": username, "content": content, "mentioned_accounts": mentioned, "responder_account": responder_account, "rule_id": matched_rule_id, "error": "create_clip_not_configured", } ) continue try: clip = self._create_clip_as( responder_account, # auth as responder account self.channel_name, # clip current channel rule_clip_has_delay, ) clip_url = (clip or {}).get("url") or "" response_text = f"Clip créé: {clip_url}".strip() except Exception as e: self.append_log( { "ts": _now_iso(), "type": "clip_error", "channel": self.channel_name, "from": username, "content": content, "mentioned_accounts": mentioned, "responder_account": responder_account, "rule_id": matched_rule_id, "error": str(e), } ) continue else: # response (préenregistrée) 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, "action": action, } ) 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, "action": rule_action or ("tgpt" if (cfg.mode == "tgpt" and cfg.tgpt_enabled) else "response"), } ) 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()