update update

This commit is contained in:
gpatruno
2026-04-28 21:06:26 +02:00
parent 7b2135bfed
commit b4254c9e06
28 changed files with 2032 additions and 547 deletions
+54 -11
View File
@@ -16,6 +16,8 @@ from fonction.first_class import (
from twitch_bot import chat_state
from twitch_bot.interaction_chat import InteractionChatProcessor
from twitch_bot import twitch_helix
from twitch_bot.subtitle_rules import SubtitleRulesProcessor
def _resolve_user_index(pseudo: str) -> int:
@@ -84,7 +86,7 @@ class BotController:
'active_flux': len([f for f in self.flux_list if f['active']])
}
def add_flux(self, channel_name, record_audio=True):
def add_flux(self, channel_name, record_audio=True, send_messages=True, enable_ia=True):
flux_id = len(self.flux_list) + 1
# Créer l'objet flux pour l'API (sans les instances de bots)
@@ -93,6 +95,8 @@ class BotController:
'name': channel_name,
'twitchname': channel_name,
'record_audio': record_audio,
'send_messages': bool(send_messages),
'enable_ia': bool(enable_ia),
'active': True,
'created_at': datetime.now().isoformat(),
'status': 'starting'
@@ -137,13 +141,42 @@ class BotController:
return {}
msg_bot_for_interaction = messageTwitch("config/user.json", channel_name)
def create_clip_as(pseudo: str, broadcaster_login: str, has_delay: bool = False):
client_id = twitch_helix.twitch_client_id("config/config.json")
bearer = twitch_helix.bearer_token_for_pseudo(pseudo, "config/user.json")
broadcaster_id = twitch_helix.get_user_id_by_login(
client_id=client_id,
bearer=bearer,
login=broadcaster_login,
)
return twitch_helix.create_clip(
client_id=client_id,
bearer=bearer,
broadcaster_id=broadcaster_id,
has_delay=bool(has_delay),
)
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
send_message_as=lambda pseudo, text: (
msg_bot_for_interaction.send_message_user(_resolve_user_index(pseudo), text)
if bool(flux_data.get("send_messages", True))
else None
),
create_clip_as=create_clip_as,
)
subtitle_rules = SubtitleRulesProcessor(
channel_name=channel_name,
send_message_as=lambda pseudo, text: (
msg_bot_for_interaction.send_message_user(_resolve_user_index(pseudo), text)
if bool(flux_data.get("send_messages", True))
else None
),
create_clip_as=create_clip_as,
resolve_default_account=lambda: (get_registered_accounts() or [""])[0],
)
self.bots[flux_id] = {
@@ -153,6 +186,7 @@ class BotController:
'ia_bot': None,
'message_bot': None,
'interaction': interaction,
'subtitle_rules': subtitle_rules,
}
chat_bot.start_background()
@@ -165,19 +199,28 @@ class BotController:
threading.Thread(target=record_bot.main, daemon=True).start()
# Démarrer le bot de sous-titres
subtitle_bot = Subtitle_translation("config/config.json")
subtitle_storage_key = f"subtitle_data__{channel_name.lower()}"
subtitle_bot = Subtitle_translation(
"config/config.json",
storage_key=subtitle_storage_key,
on_new_subtitle=lambda ts, text: subtitle_rules.on_new_subtitle(ts_key=ts, subtitle_text=text),
segment_seconds=30,
max_backlog_files=3,
)
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()
if bool(flux_data.get("enable_ia", True)):
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()
# Démarrer le contrôleur de messages (uniquement si autorisé sur ce flux)
if bool(flux_data.get("send_messages", True)) and bool(flux_data.get("enable_ia", True)):
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'
+96 -16
View File
@@ -63,8 +63,10 @@ class InteractionRule:
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":
@@ -74,8 +76,10 @@ class InteractionRule:
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]:
@@ -85,8 +89,10 @@ class InteractionRule:
"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:
@@ -109,8 +115,10 @@ 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))
# 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))
@@ -174,12 +182,14 @@ class InteractionChatProcessor:
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"
@@ -330,11 +340,14 @@ class InteractionChatProcessor:
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("@"):
@@ -342,6 +355,7 @@ class InteractionChatProcessor:
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
@@ -350,20 +364,85 @@ class InteractionChatProcessor:
# 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
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()
@@ -409,6 +488,7 @@ class InteractionChatProcessor:
"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"),
}
)
+271
View File
@@ -0,0 +1,271 @@
import json
import threading
import time
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Dict, List, Optional
def _now_iso() -> str:
return datetime.now().isoformat(timespec="seconds")
def _contains_case_insensitive(haystack: str, needle: str) -> bool:
return needle.lower() in haystack.lower()
class SubtitleRulesStorage:
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 f"{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:
import os
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)
@dataclass
class SubtitleRule:
id: str
enabled: bool = True
contains_text: Optional[str] = None
action: str = "clip" # clip | send_message
as_account: Optional[str] = None # pseudo du compte bot (config/user.json). si None: 1er compte enabled
message_text: str = "" # utilisé si action=send_message
clip_has_delay: bool = False
clip_send_message: bool = False
clip_message_template: str = "Clip: {url}"
cooldown_seconds: int = 10
@staticmethod
def from_dict(d: Dict[str, Any]) -> "SubtitleRule":
return SubtitleRule(
id=str(d.get("id") or ""),
enabled=bool(d.get("enabled", True)),
contains_text=(d.get("contains_text") or None),
action=str(d.get("action") or "clip"),
as_account=(d.get("as_account") or None),
message_text=str(d.get("message_text") or ""),
clip_has_delay=bool(d.get("clip_has_delay", False)),
clip_send_message=bool(d.get("clip_send_message", False)),
clip_message_template=str(d.get("clip_message_template") or "Clip: {url}"),
cooldown_seconds=int(d.get("cooldown_seconds", 10)),
)
def to_dict(self) -> Dict[str, Any]:
return {
"id": self.id,
"enabled": self.enabled,
"contains_text": self.contains_text,
"action": self.action,
"as_account": self.as_account,
"message_text": self.message_text,
"clip_has_delay": self.clip_has_delay,
"clip_send_message": self.clip_send_message,
"clip_message_template": self.clip_message_template,
"cooldown_seconds": self.cooldown_seconds,
}
def matches(self, subtitle_text: str) -> bool:
if not self.enabled:
return False
if self.contains_text and not _contains_case_insensitive(subtitle_text or "", self.contains_text):
return False
return True
class SubtitleRulesConfig:
def __init__(self, data: Optional[Dict[str, Any]] = None):
data = data or {}
self.enabled: bool = bool(data.get("enabled", False))
self.rules: List[SubtitleRule] = [
SubtitleRule.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,
"rules": [r.to_dict() for r in self.rules],
}
class SubtitleRulesProcessor:
def __init__(
self,
*,
channel_name: str,
send_message_as: callable,
create_clip_as: callable,
resolve_default_account: callable,
storage: Optional[SubtitleRulesStorage] = None,
):
self.channel_name = channel_name
self._send_message_as = send_message_as
self._create_clip_as = create_clip_as
self._resolve_default_account = resolve_default_account
self._storage = storage or SubtitleRulesStorage()
self._config_file = "subtitle_rules_config"
self._log_file = "subtitle_rules_log"
self._last_fired_by_rule: Dict[str, float] = {}
def load_config(self) -> SubtitleRulesConfig:
data = self._storage.read_json(self._config_file, default={})
return SubtitleRulesConfig(data)
def save_config(self, config_dict: Dict[str, Any]) -> SubtitleRulesConfig:
cfg = SubtitleRulesConfig(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[-400:]
self._storage.write_json(self._log_file, logs)
def read_log(self, limit: int = 120) -> List[Dict[str, Any]]:
logs = self._storage.read_json(self._log_file, default=[])
if not isinstance(logs, list):
return []
return logs[-limit:]
def _cooldown_ok(self, rule_id: str, cooldown_s: int) -> bool:
last = self._last_fired_by_rule.get(rule_id)
if last is None:
return True
return (time.time() - last) >= max(0, cooldown_s)
def _mark_fired(self, rule_id: str) -> None:
self._last_fired_by_rule[rule_id] = time.time()
def on_new_subtitle(self, *, ts_key: str, subtitle_text: str) -> None:
cfg = self.load_config()
if not cfg.enabled:
return
text = str(subtitle_text or "")
for r in cfg.rules:
if not r.id:
continue
if not r.matches(text):
continue
if not self._cooldown_ok(r.id, r.cooldown_seconds):
continue
account = (r.as_account or "").strip() or self._resolve_default_account()
action = (r.action or "clip").strip().lower()
if action == "send_message":
msg = (r.message_text or "").strip()
if not msg:
self.append_log(
{
"ts": _now_iso(),
"type": "rule_empty_message",
"channel": self.channel_name,
"subtitle_ts": ts_key,
"subtitle": text,
"rule_id": r.id,
}
)
continue
try:
self._send_message_as(account, msg)
self._mark_fired(r.id)
self.append_log(
{
"ts": _now_iso(),
"type": "message_sent",
"channel": self.channel_name,
"subtitle_ts": ts_key,
"subtitle": text,
"rule_id": r.id,
"as_account": account,
"message": msg,
}
)
except Exception as e:
self.append_log(
{
"ts": _now_iso(),
"type": "error",
"channel": self.channel_name,
"subtitle_ts": ts_key,
"subtitle": text,
"rule_id": r.id,
"action": action,
"error": str(e),
}
)
continue
# default: clip
try:
clip = self._create_clip_as(account, self.channel_name, bool(r.clip_has_delay))
clip_id = (clip or {}).get("id") or ""
clip_url = (clip or {}).get("url") or ""
clip_edit_url = (clip or {}).get("edit_url") or ""
sent_message = None
if bool(getattr(r, "clip_send_message", False)):
tpl = str(getattr(r, "clip_message_template", "") or "Clip: {url}")
msg = (
tpl.replace("{url}", str(clip_url))
.replace("{edit_url}", str(clip_edit_url))
.replace("{id}", str(clip_id))
).strip()
if msg:
self._send_message_as(account, msg)
sent_message = msg
self._mark_fired(r.id)
self.append_log(
{
"ts": _now_iso(),
"type": "clip_created",
"channel": self.channel_name,
"subtitle_ts": ts_key,
"subtitle": text,
"rule_id": r.id,
"as_account": account,
"clip_url": clip_url,
"clip_edit_url": clip_edit_url,
"clip_id": clip_id,
"chat_message": sent_message,
}
)
except Exception as e:
self.append_log(
{
"ts": _now_iso(),
"type": "error",
"channel": self.channel_name,
"subtitle_ts": ts_key,
"subtitle": text,
"rule_id": r.id,
"action": action,
"error": str(e),
}
)
+125
View File
@@ -0,0 +1,125 @@
import json
from typing import Any, Dict, List, Optional
import requests
def _read_json(path: str, default):
try:
with open(path, "r", encoding="utf-8") as f:
v = json.load(f)
return v if v is not None else default
except Exception:
return default
def twitch_client_id(config_path: str = "config/config.json") -> str:
cfg = _read_json(config_path, default={})
if isinstance(cfg, dict):
v = cfg.get("twitch_client_id") or cfg.get("twitch_clientId") or cfg.get("twitchClientId")
if isinstance(v, str):
return v.strip()
return ""
def _normalize_bearer(raw: str) -> str:
raw = (raw or "").strip()
if raw.lower().startswith("oauth:"):
raw = raw.split(":", 1)[1].strip()
return raw
def bearer_token_for_pseudo(pseudo: str, users_path: str = "config/user.json") -> str:
users = _read_json(users_path, default=[])
if not isinstance(users, list):
users = []
wanted = (pseudo or "").strip().lstrip("@").lower()
for u in users:
if not isinstance(u, dict):
continue
p = (u.get("tw_acc_pseudo") or "").strip().lower()
if p == wanted:
tok = _normalize_bearer(u.get("tw_acc_token") or "")
if not tok:
raise ValueError("Token OAuth manquant")
return tok
raise ValueError("Utilisateur (pseudo) non trouvé")
def _helix_headers(*, client_id: str, bearer: str) -> Dict[str, str]:
if not client_id:
raise ValueError("Client-ID Twitch manquant (config/config.json:twitch_client_id)")
if not bearer:
raise ValueError("Token OAuth manquant")
return {"Client-ID": client_id, "Authorization": f"Bearer {bearer}"}
def get_user_id_by_login(*, client_id: str, bearer: str, login: str, timeout_s: int = 10) -> str:
login = (login or "").strip().lstrip("@")
if not login:
raise ValueError("Nom de chaîne requis")
r = requests.get(
"https://api.twitch.tv/helix/users",
headers=_helix_headers(client_id=client_id, bearer=bearer),
params={"login": login},
timeout=timeout_s,
)
payload: Optional[Dict[str, Any]]
try:
payload = r.json()
except Exception:
payload = None
if r.status_code >= 400:
msg = ""
if isinstance(payload, dict):
msg = payload.get("message") or payload.get("error") or ""
raise RuntimeError(f"Erreur Twitch /helix/users ({r.status_code}) {msg}".strip())
data = payload.get("data") if isinstance(payload, dict) else None
if not isinstance(data, list) or not data:
raise ValueError("Chaîne Twitch introuvable (login)")
uid = (data[0] or {}).get("id")
if not uid:
raise RuntimeError("Réponse Twitch invalide: id manquant")
return str(uid)
def create_clip(
*,
client_id: str,
bearer: str,
broadcaster_id: str,
has_delay: bool = False,
timeout_s: int = 15,
) -> Dict[str, str]:
r = requests.post(
"https://api.twitch.tv/helix/clips",
headers=_helix_headers(client_id=client_id, bearer=bearer),
params={"broadcaster_id": str(broadcaster_id), "has_delay": "true" if has_delay else "false"},
timeout=timeout_s,
)
payload: Optional[Dict[str, Any]]
try:
payload = r.json()
except Exception:
payload = None
if r.status_code >= 400:
msg = ""
if isinstance(payload, dict):
msg = payload.get("message") or payload.get("error") or ""
hint = ""
if r.status_code in (401, 403):
hint = " (scope requis: clips:edit)"
raise RuntimeError(f"Erreur Twitch /helix/clips ({r.status_code}) {msg}{hint}".strip())
data = payload.get("data") if isinstance(payload, dict) else None
if not isinstance(data, list) or not data:
raise RuntimeError("Réponse Twitch invalide: data manquant")
clip = data[0] or {}
clip_id = clip.get("id")
edit_url = clip.get("edit_url")
if not clip_id:
raise RuntimeError("Réponse Twitch invalide: clip id manquant")
return {"id": str(clip_id), "edit_url": str(edit_url or ""), "url": f"https://clips.twitch.tv/{clip_id}"}