update update
This commit is contained in:
+54
-11
@@ -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'
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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}"}
|
||||
|
||||
Reference in New Issue
Block a user