Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b1f3d6e190 | |||
| b4254c9e06 |
@@ -1 +0,0 @@
|
|||||||
{"text": " J'fais de la merde, attends. Faut que j'enl\u00e8ve tout sauf le nom. C'est quoi son nom ? Vas-y, \u00e7a va. Bah ouais, chaque fois qu'on demande les heures, KennyChill il est \u00e0 30, euh... Nous on est \u00e0 15, quoi. En fait, on a 3 m\u00e8tres, on a 3 m\u00e8tres partout. Age of Empires c'est tellement, tellement herdif. C'est inhumain, en fait. On doit en avoir...", "segments": [{"id": 0, "seek": 0, "start": 0.0, "end": 2.0, "text": " J'fais de la merde, attends.", "tokens": [50365, 508, 6, 69, 1527, 368, 635, 45772, 11, 49837, 13, 50465], "temperature": 0.0, "avg_logprob": -0.3589739685058594, "compression_ratio": 1.4646017699115044, "no_speech_prob": 0.7780718803405762}, {"id": 1, "seek": 0, "start": 4.0, "end": 6.0, "text": " Faut que j'enl\u00e8ve tout sauf le nom. C'est quoi son nom ?", "tokens": [50565, 479, 1375, 631, 361, 6, 268, 75, 31397, 3486, 601, 2947, 476, 5369, 13, 383, 6, 377, 11714, 1872, 5369, 2506, 50665], "temperature": 0.0, "avg_logprob": -0.3589739685058594, "compression_ratio": 1.4646017699115044, "no_speech_prob": 0.7780718803405762}, {"id": 2, "seek": 0, "start": 6.0, "end": 8.0, "text": " Vas-y, \u00e7a va.", "tokens": [50665, 23299, 12, 88, 11, 2788, 2773, 13, 50765], "temperature": 0.0, "avg_logprob": -0.3589739685058594, "compression_ratio": 1.4646017699115044, "no_speech_prob": 0.7780718803405762}, {"id": 3, "seek": 0, "start": 12.0, "end": 16.0, "text": " Bah ouais, chaque fois qu'on demande les heures, KennyChill il est \u00e0 30, euh...", "tokens": [50965, 14782, 30570, 11, 18920, 9576, 421, 6, 266, 26982, 1512, 28509, 11, 33681, 6546, 373, 1930, 871, 1531, 2217, 11, 32678, 485, 51165], "temperature": 0.0, "avg_logprob": -0.3589739685058594, "compression_ratio": 1.4646017699115044, "no_speech_prob": 0.7780718803405762}, {"id": 4, "seek": 0, "start": 17.0, "end": 19.0, "text": " Nous on est \u00e0 15, quoi.", "tokens": [51215, 15343, 322, 871, 1531, 2119, 11, 11714, 13, 51315], "temperature": 0.0, "avg_logprob": -0.3589739685058594, "compression_ratio": 1.4646017699115044, "no_speech_prob": 0.7780718803405762}, {"id": 5, "seek": 0, "start": 20.0, "end": 22.0, "text": " En fait, on a 3 m\u00e8tres, on a 3 m\u00e8tres partout.", "tokens": [51365, 2193, 3887, 11, 322, 257, 805, 275, 36506, 11, 322, 257, 805, 275, 36506, 32955, 13, 51465], "temperature": 0.0, "avg_logprob": -0.3589739685058594, "compression_ratio": 1.4646017699115044, "no_speech_prob": 0.7780718803405762}, {"id": 6, "seek": 0, "start": 23.0, "end": 26.0, "text": " Age of Empires c'est tellement, tellement herdif.", "tokens": [51515, 16280, 295, 8599, 3145, 269, 6, 377, 28906, 11, 28906, 29484, 351, 13, 51665], "temperature": 0.0, "avg_logprob": -0.3589739685058594, "compression_ratio": 1.4646017699115044, "no_speech_prob": 0.7780718803405762}, {"id": 7, "seek": 0, "start": 26.0, "end": 28.0, "text": " C'est inhumain, en fait.", "tokens": [51665, 383, 6, 377, 294, 14645, 491, 11, 465, 3887, 13, 51765], "temperature": 0.0, "avg_logprob": -0.3589739685058594, "compression_ratio": 1.4646017699115044, "no_speech_prob": 0.7780718803405762}, {"id": 8, "seek": 2800, "start": 28.0, "end": 30.0, "text": " On doit en avoir...", "tokens": [50365, 1282, 19193, 465, 10853, 485, 50465], "temperature": 0.0, "avg_logprob": -0.38779303431510925, "compression_ratio": 0.7037037037037037, "no_speech_prob": 0.5220985412597656}], "language": "fr"}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
1
|
|
||||||
00:00:00,000 --> 00:00:02,000
|
|
||||||
J'fais de la merde, attends.
|
|
||||||
|
|
||||||
2
|
|
||||||
00:00:04,000 --> 00:00:06,000
|
|
||||||
Faut que j'enlève tout sauf le nom. C'est quoi son nom ?
|
|
||||||
|
|
||||||
3
|
|
||||||
00:00:06,000 --> 00:00:08,000
|
|
||||||
Vas-y, ça va.
|
|
||||||
|
|
||||||
4
|
|
||||||
00:00:12,000 --> 00:00:16,000
|
|
||||||
Bah ouais, chaque fois qu'on demande les heures, KennyChill il est à 30, euh...
|
|
||||||
|
|
||||||
5
|
|
||||||
00:00:17,000 --> 00:00:19,000
|
|
||||||
Nous on est à 15, quoi.
|
|
||||||
|
|
||||||
6
|
|
||||||
00:00:20,000 --> 00:00:22,000
|
|
||||||
En fait, on a 3 mètres, on a 3 mètres partout.
|
|
||||||
|
|
||||||
7
|
|
||||||
00:00:23,000 --> 00:00:26,000
|
|
||||||
Age of Empires c'est tellement, tellement herdif.
|
|
||||||
|
|
||||||
8
|
|
||||||
00:00:26,000 --> 00:00:28,000
|
|
||||||
C'est inhumain, en fait.
|
|
||||||
|
|
||||||
9
|
|
||||||
00:00:28,000 --> 00:00:30,000
|
|
||||||
On doit en avoir...
|
|
||||||
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
start end text
|
|
||||||
0 2000 J'fais de la merde, attends.
|
|
||||||
4000 6000 Faut que j'enlève tout sauf le nom. C'est quoi son nom ?
|
|
||||||
6000 8000 Vas-y, ça va.
|
|
||||||
12000 16000 Bah ouais, chaque fois qu'on demande les heures, KennyChill il est à 30, euh...
|
|
||||||
17000 19000 Nous on est à 15, quoi.
|
|
||||||
20000 22000 En fait, on a 3 mètres, on a 3 mètres partout.
|
|
||||||
23000 26000 Age of Empires c'est tellement, tellement herdif.
|
|
||||||
26000 28000 C'est inhumain, en fait.
|
|
||||||
28000 30000 On doit en avoir...
|
|
||||||
|
@@ -1,9 +0,0 @@
|
|||||||
J'fais de la merde, attends.
|
|
||||||
Faut que j'enlève tout sauf le nom. C'est quoi son nom ?
|
|
||||||
Vas-y, ça va.
|
|
||||||
Bah ouais, chaque fois qu'on demande les heures, KennyChill il est à 30, euh...
|
|
||||||
Nous on est à 15, quoi.
|
|
||||||
En fait, on a 3 mètres, on a 3 mètres partout.
|
|
||||||
Age of Empires c'est tellement, tellement herdif.
|
|
||||||
C'est inhumain, en fait.
|
|
||||||
On doit en avoir...
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
WEBVTT
|
|
||||||
|
|
||||||
00:00.000 --> 00:02.000
|
|
||||||
J'fais de la merde, attends.
|
|
||||||
|
|
||||||
00:04.000 --> 00:06.000
|
|
||||||
Faut que j'enlève tout sauf le nom. C'est quoi son nom ?
|
|
||||||
|
|
||||||
00:06.000 --> 00:08.000
|
|
||||||
Vas-y, ça va.
|
|
||||||
|
|
||||||
00:12.000 --> 00:16.000
|
|
||||||
Bah ouais, chaque fois qu'on demande les heures, KennyChill il est à 30, euh...
|
|
||||||
|
|
||||||
00:17.000 --> 00:19.000
|
|
||||||
Nous on est à 15, quoi.
|
|
||||||
|
|
||||||
00:20.000 --> 00:22.000
|
|
||||||
En fait, on a 3 mètres, on a 3 mètres partout.
|
|
||||||
|
|
||||||
00:23.000 --> 00:26.000
|
|
||||||
Age of Empires c'est tellement, tellement herdif.
|
|
||||||
|
|
||||||
00:26.000 --> 00:28.000
|
|
||||||
C'est inhumain, en fait.
|
|
||||||
|
|
||||||
00:28.000 --> 00:30.000
|
|
||||||
On doit en avoir...
|
|
||||||
|
|
||||||
@@ -8,8 +8,11 @@ Bot Twitch qui enregistre l’audio d’un stream, le transcrit en texte, analys
|
|||||||
- Transcription / alignement temporel (workflow prévu avec [WhisperX](https://github.com/m-bain/whisperX)).
|
- Transcription / alignement temporel (workflow prévu avec [WhisperX](https://github.com/m-bain/whisperX)).
|
||||||
- Génération de réponses et stockage des générations (fichiers JSON sous `storage/`).
|
- Génération de réponses et stockage des générations (fichiers JSON sous `storage/`).
|
||||||
- Lecture et envoi sur le chat Twitch (OAuth, utilisateurs dans `config/user.json`).
|
- Lecture et envoi sur le chat Twitch (OAuth, utilisateurs dans `config/user.json`).
|
||||||
- **Interaction chat (mentions)** : si un viewer mentionne un des comptes enregistrés (ex: `@exoticnaturees`), le bot peut répondre automatiquement via **réponses préenregistrées** et **règles conditionnelles** (page “Interaction chat”).
|
- **Interaction chat (mentions)** : si un viewer mentionne un des comptes enregistrés (ex: `@exoticnaturees`), le bot peut répondre automatiquement via **réponses**, **TGPT**, ou **création de clip** (via règles).
|
||||||
- **Interface web** (Flask + Socket.IO) : gestion des flux, toggles IA / contrôle Twitch, sous-titres automatiques, etc.
|
- **Clips** : création manuelle de clips via l’API Helix (page “Clips”) + actions “Créer un clip” dans les règles (Interaction chat / Sous-titres).
|
||||||
|
- **Sous-titres multi-flux** : stockage séparé par flux + sélection du flux dans l’onglet “Sous-titres”.
|
||||||
|
- **Règles sur sous-titres** : déclencher des actions (clip / message) quand un sous-titre contient un mot/texte.
|
||||||
|
- **Interface web** (Flask + Socket.IO) : gestion des flux, sous-titres, règles, prompts IA, etc.
|
||||||
|
|
||||||
L’architecture détaillée des routes et composants est décrite dans [README_ARCHITECTURE.md](README_ARCHITECTURE.md). L’interface utilisateur côté navigateur est décrite dans [README_WEB_INTERFACE.md](README_WEB_INTERFACE.md).
|
L’architecture détaillée des routes et composants est décrite dans [README_ARCHITECTURE.md](README_ARCHITECTURE.md). L’interface utilisateur côté navigateur est décrite dans [README_WEB_INTERFACE.md](README_WEB_INTERFACE.md).
|
||||||
|
|
||||||
@@ -55,8 +58,21 @@ pip install -r requirements.txt
|
|||||||
|
|
||||||
Avant de lancer l’interface, créez ou complétez au minimum :
|
Avant de lancer l’interface, créez ou complétez au minimum :
|
||||||
|
|
||||||
- `config/config.json` — paramètres du bot (chemins, prompts, etc.).
|
- `config/config.json` — paramètres du bot (prompts, limites, **`twitch_client_id`** pour Helix clips).
|
||||||
- `config/user.json` — utilisateurs Twitch pour l’envoi des messages.
|
- `config/user.json` — utilisateurs Twitch (OAuth) pour envoyer des messages et appeler Helix (clips).
|
||||||
|
|
||||||
|
#### Twitch Client-ID (Helix)
|
||||||
|
|
||||||
|
Pour créer des clips (page “Clips” + règles “Créer un clip”), ajoutez :
|
||||||
|
|
||||||
|
- `twitch_client_id` dans `config/config.json`
|
||||||
|
|
||||||
|
#### Scopes OAuth requis
|
||||||
|
|
||||||
|
- **Envoyer des messages chat** : `chat:edit` (souvent + `chat:read`)
|
||||||
|
- **Créer des clips** : `clips:edit`
|
||||||
|
|
||||||
|
Astuce : vous pouvez générer **un seul token** avec les scopes `clips:edit chat:edit chat:read` et le mettre dans `config/user.json`.
|
||||||
|
|
||||||
Le script `start_web_interface.py` vérifie la présence de ces fichiers et crée les dossiers `storage/`, `record/`, `in_record/` si besoin.
|
Le script `start_web_interface.py` vérifie la présence de ces fichiers et crée les dossiers `storage/`, `record/`, `in_record/` si besoin.
|
||||||
|
|
||||||
@@ -85,9 +101,26 @@ L’interface écoute par défaut sur **http://0.0.0.0:5000** (voir `start_web_i
|
|||||||
- `storage/interaction_chat_config.json`
|
- `storage/interaction_chat_config.json`
|
||||||
- `storage/interaction_chat_log.json`
|
- `storage/interaction_chat_log.json`
|
||||||
|
|
||||||
### Mode TGPT
|
### Mode TGPT + Actions de règles
|
||||||
|
|
||||||
- Le mode **TGPT** est disponible: il envoie le message du viewer à `tgpt` avec un **préprompt** (global ou par règle), puis renvoie la réponse.
|
- Le mode **TGPT** est disponible : il envoie le message du viewer à `tgpt` avec un **préprompt** (global ou par règle), puis renvoie la réponse.
|
||||||
|
- Les règles peuvent aussi déclencher l’action **Créer un clip**.
|
||||||
|
|
||||||
|
## Sous-titres (multi-flux) + règles
|
||||||
|
|
||||||
|
- Chaque flux écrit ses sous-titres dans un fichier séparé : `storage/subtitle_data__<channel>.json`.
|
||||||
|
- L’onglet **Sous-titres** permet de choisir le flux à afficher.
|
||||||
|
- Les règles “Sous-titres → actions” sont stockées sous :
|
||||||
|
- `storage/subtitle_rules_config.json`
|
||||||
|
- `storage/subtitle_rules_log.json` (créé après la 1ère exécution)
|
||||||
|
|
||||||
|
## Flux : options par flux
|
||||||
|
|
||||||
|
Lors de l’ajout d’un flux dans l’interface web, vous pouvez configurer :
|
||||||
|
|
||||||
|
- **Enregistrer l’audio du stream**
|
||||||
|
- **Autoriser l’envoi de messages sur ce stream** (bloque l’envoi auto et les actions “message” de ce flux)
|
||||||
|
- **Activer génération TGPT (IA) à partir des sous-titres** (si OFF : pas d’IA_generator et pas d’envoi auto basé sur TGPT)
|
||||||
|
|
||||||
Mode terminal sans interface web :
|
Mode terminal sans interface web :
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ Le TwitchBot Controller a été refactorisé pour utiliser une architecture cent
|
|||||||
- **RecordTwitch** : Enregistrement audio des streams
|
- **RecordTwitch** : Enregistrement audio des streams
|
||||||
- **Subtitle_translation** : Traitement des sous-titres
|
- **Subtitle_translation** : Traitement des sous-titres
|
||||||
- **TwitchChatBot** : Connexion aux chats Twitch
|
- **TwitchChatBot** : Connexion aux chats Twitch
|
||||||
|
- **InteractionChatProcessor** : Règles mentions @ (réponse / TGPT / clip)
|
||||||
|
- **SubtitleRulesProcessor** : Règles sur sous-titres (clip / message)
|
||||||
|
|
||||||
## 🔧 Fonctionnalités
|
## 🔧 Fonctionnalités
|
||||||
|
|
||||||
@@ -34,12 +36,14 @@ Le TwitchBot Controller a été refactorisé pour utiliser une architecture cent
|
|||||||
- ✅ Activer/désactiver les flux
|
- ✅ Activer/désactiver les flux
|
||||||
- ✅ Surveillance en temps réel
|
- ✅ Surveillance en temps réel
|
||||||
- ✅ Gestion des erreurs
|
- ✅ Gestion des erreurs
|
||||||
|
- ✅ Options par flux : `send_messages`, `enable_ia`
|
||||||
|
|
||||||
#### Contrôle des composants
|
#### Contrôle des composants
|
||||||
- ✅ **IA Generator** : Démarrage/arrêt depuis l'interface
|
- ✅ **IA Generator** : Démarrage/arrêt depuis l'interface
|
||||||
- ✅ **Control Twitch** : Contrôle de l'envoi de messages
|
- ✅ **Control Twitch** : Contrôle de l'envoi de messages
|
||||||
- ✅ **Auto Subtitle** : Génération automatique de sous-titres
|
- ✅ **Auto Subtitle** : Génération automatique de sous-titres
|
||||||
- ✅ **Auto Messages** : Envoi automatique de messages
|
- ✅ **Auto Messages** : Envoi automatique de messages
|
||||||
|
- ✅ **Clips (Helix)** : Création manuelle + actions via règles
|
||||||
|
|
||||||
#### Gestion des utilisateurs
|
#### Gestion des utilisateurs
|
||||||
- ✅ Ajouter/modifier/supprimer des utilisateurs Twitch
|
- ✅ Ajouter/modifier/supprimer des utilisateurs Twitch
|
||||||
@@ -111,6 +115,12 @@ Sous-titres → IA_generator → Réponses IA → Stockage
|
|||||||
Stockage → Control_Twitch → Chat Twitch
|
Stockage → Control_Twitch → Chat Twitch
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 4. Règles
|
||||||
|
```
|
||||||
|
Chat (mentions @) → InteractionChatProcessor → (réponse | TGPT | clip)
|
||||||
|
Sous-titres → SubtitleRulesProcessor → (clip | message)
|
||||||
|
```
|
||||||
|
|
||||||
## 🛠️ API Endpoints
|
## 🛠️ API Endpoints
|
||||||
|
|
||||||
### Flux
|
### Flux
|
||||||
@@ -129,6 +139,15 @@ Stockage → Control_Twitch → Chat Twitch
|
|||||||
- `GET /api/system-status` : Statut de tous les composants
|
- `GET /api/system-status` : Statut de tous les composants
|
||||||
- `GET /api/status` : Statut général
|
- `GET /api/status` : Statut général
|
||||||
|
|
||||||
|
### Clips
|
||||||
|
- `POST /api/clips/create` : Créer un clip (Helix)
|
||||||
|
|
||||||
|
### Sous-titres
|
||||||
|
- `GET /api/subtitles?flux_id=...` : Sous-titres d’un flux
|
||||||
|
- `GET /api/subtitles/last?flux_id=...` : Dernier sous-titre “utile” d’un flux
|
||||||
|
- `GET/POST /api/subtitles/rules/config` : Config règles sous-titres
|
||||||
|
- `GET /api/subtitles/rules/log` : Logs règles sous-titres
|
||||||
|
|
||||||
## 🔍 Résolution des problèmes
|
## 🔍 Résolution des problèmes
|
||||||
|
|
||||||
### Problèmes résolus
|
### Problèmes résolus
|
||||||
|
|||||||
+20
-1
@@ -12,6 +12,9 @@ Une interface web moderne en dark mode pour contrôler votre bot Twitch intellig
|
|||||||
### 🎥 Gestion des Flux
|
### 🎥 Gestion des Flux
|
||||||
- **Ajout de flux** : Ajouter des canaux Twitch à surveiller
|
- **Ajout de flux** : Ajouter des canaux Twitch à surveiller
|
||||||
- **Contrôle audio** : Choisir d'enregistrer ou non l'audio du stream
|
- **Contrôle audio** : Choisir d'enregistrer ou non l'audio du stream
|
||||||
|
- **Options par flux** :
|
||||||
|
- Autoriser l’envoi de messages sur ce stream
|
||||||
|
- Activer / désactiver la génération TGPT (IA) à partir des sous-titres
|
||||||
- **Surveillance chat** : Connexion automatique au chat de chaque flux
|
- **Surveillance chat** : Connexion automatique au chat de chaque flux
|
||||||
- **Gestion dynamique** : Ajouter/supprimer des flux sans redémarrer
|
- **Gestion dynamique** : Ajouter/supprimer des flux sans redémarrer
|
||||||
|
|
||||||
@@ -32,6 +35,12 @@ Une interface web moderne en dark mode pour contrôler votre bot Twitch intellig
|
|||||||
- **Historique complet** : Liste des 10 derniers sous-titres avec timestamps
|
- **Historique complet** : Liste des 10 derniers sous-titres avec timestamps
|
||||||
- **Génération automatique** : Bouton pour générer une réponse IA à partir du texte
|
- **Génération automatique** : Bouton pour générer une réponse IA à partir du texte
|
||||||
- **Mise à jour temps réel** : Nouveaux sous-titres affichés automatiquement
|
- **Mise à jour temps réel** : Nouveaux sous-titres affichés automatiquement
|
||||||
|
- **Multi-flux** : Sélection du flux dans l’onglet Sous-titres (stockage séparé par flux)
|
||||||
|
- **Règles sur sous-titres** : Déclencher des actions (clip / message) quand un sous-titre contient un mot/texte
|
||||||
|
|
||||||
|
### 🎬 Clips
|
||||||
|
- **Création manuelle** : Page “Clips” pour créer un clip Helix (retourne URL + edit_url)
|
||||||
|
- **Actions via règles** : “Créer un clip” possible depuis Interaction chat et depuis les règles Sous-titres
|
||||||
|
|
||||||
## 🛠️ Installation
|
## 🛠️ Installation
|
||||||
|
|
||||||
@@ -61,6 +70,10 @@ L'interface web utilise les mêmes fichiers de configuration que votre bot :
|
|||||||
- `config/config.json` : Configuration principale
|
- `config/config.json` : Configuration principale
|
||||||
- `config/user.json` : Comptes Twitch pour l'envoi de messages
|
- `config/user.json` : Comptes Twitch pour l'envoi de messages
|
||||||
|
|
||||||
|
Pour Helix Clips :
|
||||||
|
- Ajoutez `twitch_client_id` dans `config/config.json`
|
||||||
|
- Tokens OAuth avec scope `clips:edit` (et pour chat : `chat:edit` / `chat:read`)
|
||||||
|
|
||||||
### Configuration automatique
|
### Configuration automatique
|
||||||
Au premier démarrage, des fichiers de configuration par défaut sont créés. Modifiez-les avec vos paramètres :
|
Au premier démarrage, des fichiers de configuration par défaut sont créés. Modifiez-les avec vos paramètres :
|
||||||
|
|
||||||
@@ -106,6 +119,7 @@ L'interface utilise un thème dark moderne avec :
|
|||||||
1. **Ajouter un flux** : Cliquez sur "Ajouter Flux"
|
1. **Ajouter un flux** : Cliquez sur "Ajouter Flux"
|
||||||
2. **Nom du canal** : Entrez le nom du canal Twitch
|
2. **Nom du canal** : Entrez le nom du canal Twitch
|
||||||
3. **Options audio** : Cochez pour enregistrer l'audio
|
3. **Options audio** : Cochez pour enregistrer l'audio
|
||||||
|
4. **Options messages / IA** : Cochez/décochez l’envoi de messages et la génération TGPT par flux
|
||||||
4. **Gestion** : Utilisez les boutons pause/play et supprimer
|
4. **Gestion** : Utilisez les boutons pause/play et supprimer
|
||||||
|
|
||||||
### Onglet Messages
|
### Onglet Messages
|
||||||
@@ -121,9 +135,11 @@ L'interface utilise un thème dark moderne avec :
|
|||||||
4. **Sauvegarder** : Bouton "Sauvegarder" pour appliquer les changements
|
4. **Sauvegarder** : Bouton "Sauvegarder" pour appliquer les changements
|
||||||
|
|
||||||
### Onglet Sous-titres
|
### Onglet Sous-titres
|
||||||
1. **Texte actuel** : Voir le dernier texte détecté du stream
|
1. **Choisir un flux** : Sélectionner le flux (multi-flux)
|
||||||
|
2. **Texte actuel** : Voir le dernier texte détecté du stream
|
||||||
2. **Générer réponse** : Cliquer pour lancer la génération IA
|
2. **Générer réponse** : Cliquer pour lancer la génération IA
|
||||||
3. **Historique** : Consulter les derniers sous-titres générés
|
3. **Historique** : Consulter les derniers sous-titres générés
|
||||||
|
4. **Règles** : Déclencher une action quand un sous-titre contient un mot/texte
|
||||||
|
|
||||||
## 🔄 Actions Rapides
|
## 🔄 Actions Rapides
|
||||||
|
|
||||||
@@ -144,6 +160,9 @@ L'interface expose une API REST pour l'intégration :
|
|||||||
- `GET/POST /api/config/prompts` : Gérer les prompts
|
- `GET/POST /api/config/prompts` : Gérer les prompts
|
||||||
- `POST /api/send-message` : Envoyer un message
|
- `POST /api/send-message` : Envoyer un message
|
||||||
- `POST /api/generate-response` : Lancer une génération IA
|
- `POST /api/generate-response` : Lancer une génération IA
|
||||||
|
- `POST /api/clips/create` : Créer un clip Helix
|
||||||
|
- `GET/POST /api/subtitles/rules/config` : Config des règles sous-titres
|
||||||
|
- `GET /api/subtitles/rules/log` : Logs des règles sous-titres
|
||||||
|
|
||||||
### WebSocket
|
### WebSocket
|
||||||
- Connexion temps réel sur le même port
|
- Connexion temps réel sur le même port
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"twitch_client_id": "9gx4ha0ytdg3h3gtl0q7bafpcvleqe",
|
||||||
"twitchname": "demo_streamer",
|
"twitchname": "demo_streamer",
|
||||||
"recordtime": 60,
|
"recordtime": 60,
|
||||||
"threads": 1,
|
"threads": 1,
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"tw_acc_pseudo": "exoticnaturees",
|
"tw_acc_pseudo": "exoticnaturees",
|
||||||
"tw_acc_token": "oauth:gjz7lnyf79rrp5bfh2xm0e8k22jnmy",
|
"tw_acc_token": "oauth:u9a1q9i1718w7c7ugd6aw3zdopox4n",
|
||||||
"charactere": "Kappa",
|
"charactere": "Kappa",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"interaction_bypass_antiloop": false
|
"interaction_bypass_antiloop": false
|
||||||
|
|||||||
+133
-19
@@ -31,6 +31,7 @@ import subprocess
|
|||||||
import pty
|
import pty
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
@@ -378,10 +379,10 @@ class RecordTwitch:
|
|||||||
os.makedirs(output_directory, exist_ok=True)
|
os.makedirs(output_directory, exist_ok=True)
|
||||||
os.makedirs(in_record_directory, exist_ok=True)
|
os.makedirs(in_record_directory, exist_ok=True)
|
||||||
|
|
||||||
|
# On encode l'heure de démarrage dans le nom pour pouvoir reconstruire une timeline stable
|
||||||
|
# et limiter le "drift" causé par le temps de transcription.
|
||||||
timestamp_complet = datetime.now().strftime("%Y%m%d-%H%M%S")
|
timestamp_complet = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
timestamp = datetime.now().strftime("%Y-%m-%d")
|
output_file_path = os.path.join(in_record_directory, f"{timestamp_complet}_%03d.mp3")
|
||||||
|
|
||||||
output_file_path = os.path.join(in_record_directory, f"{timestamp}_%03d.mp3")
|
|
||||||
|
|
||||||
sprint(self.script_name,"green", f"start record")
|
sprint(self.script_name,"green", f"start record")
|
||||||
thread_compteur = Thread(target=self.compteur, daemon=True)
|
thread_compteur = Thread(target=self.compteur, daemon=True)
|
||||||
@@ -565,13 +566,24 @@ class TwitchChatBot:
|
|||||||
|
|
||||||
|
|
||||||
class Subtitle_translation:
|
class Subtitle_translation:
|
||||||
def __init__(self, pathjson):
|
def __init__(
|
||||||
|
self,
|
||||||
|
pathjson,
|
||||||
|
*,
|
||||||
|
storage_key: str = "subtitle_data",
|
||||||
|
on_new_subtitle: Optional[callable] = None,
|
||||||
|
segment_seconds: int = 30,
|
||||||
|
max_backlog_files: int = 3,
|
||||||
|
):
|
||||||
self.script_name = "translation"
|
self.script_name = "translation"
|
||||||
self.type_debug = "Debug"
|
self.type_debug = "Debug"
|
||||||
self.pathjson = pathjson
|
self.pathjson = pathjson
|
||||||
self.reload_json()
|
self.reload_json()
|
||||||
self.dir_whisperX = "whisperX"
|
self.dir_whisperX = "whisperX"
|
||||||
self.filename_memory = "subtitle_data"
|
self.filename_memory = storage_key
|
||||||
|
self._on_new_subtitle = on_new_subtitle
|
||||||
|
self.segment_seconds = int(segment_seconds) if segment_seconds else 30
|
||||||
|
self.max_backlog_files = int(max_backlog_files) if max_backlog_files else 3
|
||||||
self.subtitle = {}
|
self.subtitle = {}
|
||||||
self.all_subtitle = ""
|
self.all_subtitle = ""
|
||||||
self.is_running = True
|
self.is_running = True
|
||||||
@@ -594,14 +606,37 @@ class Subtitle_translation:
|
|||||||
|
|
||||||
|
|
||||||
def verif_file_transcribe(self):
|
def verif_file_transcribe(self):
|
||||||
for file in os.listdir(self.dir_record):
|
# Traitement ordonné + rattrapage si backlog (sinon le décalage s'accumule).
|
||||||
file_path = os.path.join(self.dir_record, file)
|
files = [f for f in os.listdir(self.dir_record) if os.path.isfile(os.path.join(self.dir_record, f)) and f.endswith(".mp3")]
|
||||||
if os.path.isfile(file_path):
|
if not files:
|
||||||
# sprint(self.script_name,"blue",f"File find -> transcribe.")
|
return
|
||||||
debug_print("d", "Creation subtitle : "+file_path, self.type_debug, self.script_name)
|
|
||||||
# Exécuter une fonction sur le fichier
|
# Trier par index de segment si possible, sinon lexicographique
|
||||||
self.auto_create_subtitle(file)
|
def _seg_index(name: str) -> int:
|
||||||
del_pathfile(file_path)
|
try:
|
||||||
|
return int(name.split("_")[-1].split(".")[0])
|
||||||
|
except Exception:
|
||||||
|
return 10**9
|
||||||
|
|
||||||
|
files = sorted(files, key=lambda n: (_seg_index(n), n))
|
||||||
|
|
||||||
|
# Si la transcription n'arrive plus à suivre, on jette des fichiers anciens pour recoller au live.
|
||||||
|
if self.max_backlog_files > 0 and len(files) > self.max_backlog_files:
|
||||||
|
to_drop = files[: max(0, len(files) - self.max_backlog_files)]
|
||||||
|
for f in to_drop:
|
||||||
|
try:
|
||||||
|
del_pathfile(os.path.join(self.dir_record, f))
|
||||||
|
sprint(self.script_name, "yellow", f"Backlog trop grand -> drop segment: {f}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
files = files[len(to_drop) :]
|
||||||
|
|
||||||
|
# Traiter 1 fichier à la fois (GPU whisper) pour éviter la concurrence
|
||||||
|
file = files[0]
|
||||||
|
file_path = os.path.join(self.dir_record, file)
|
||||||
|
debug_print("d", "Creation subtitle : "+file_path, self.type_debug, self.script_name)
|
||||||
|
self.auto_create_subtitle(file)
|
||||||
|
del_pathfile(file_path)
|
||||||
|
|
||||||
|
|
||||||
def remove_repetitions(self, message):
|
def remove_repetitions(self, message):
|
||||||
@@ -659,12 +694,30 @@ class Subtitle_translation:
|
|||||||
# num_ext = parties[-1] # Prend la dernière partie qui contient "007.mp3"
|
# num_ext = parties[-1] # Prend la dernière partie qui contient "007.mp3"
|
||||||
speak_found = self.remove_repetitions(speak_found)
|
speak_found = self.remove_repetitions(speak_found)
|
||||||
self.all_subtitle += speak_found+"\n"
|
self.all_subtitle += speak_found+"\n"
|
||||||
|
# Utiliser un timestamp stable dérivé du nom de fichier + index segment,
|
||||||
|
# plutôt que l'heure de FIN de transcription (qui dérive si whisper est lent).
|
||||||
current_time = get_current_time()
|
current_time = get_current_time()
|
||||||
|
try:
|
||||||
|
import re
|
||||||
|
m = re.match(r"^(?P<base>\d{8}-\d{6})_(?P<idx>\d+)\.mp3$", str(file))
|
||||||
|
if m:
|
||||||
|
base_dt = datetime.strptime(m.group("base"), "%Y%m%d-%H%M%S")
|
||||||
|
idx = int(m.group("idx"))
|
||||||
|
dt = base_dt + timedelta(seconds=idx * int(self.segment_seconds))
|
||||||
|
current_time = dt.strftime("%H:%M:%S")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
# if self.subtitle:
|
# if self.subtitle:
|
||||||
self.subtitle[str(current_time)] = speak_found
|
self.subtitle[str(current_time)] = speak_found
|
||||||
storage.write(self.filename_memory, str(current_time), speak_found)
|
storage.write(self.filename_memory, str(current_time), speak_found)
|
||||||
sprint(self.script_name,"yellow", "parole du streamer : \n" +speak_found)
|
sprint(self.script_name,"yellow", "parole du streamer : \n" +speak_found)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._on_new_subtitle:
|
||||||
|
self._on_new_subtitle(str(current_time), speak_found)
|
||||||
|
except Exception as e:
|
||||||
|
sprint(self.script_name, "red", f"on_new_subtitle error: {e}")
|
||||||
|
|
||||||
sprint(self.script_name,"green","finish create_subtitle")
|
sprint(self.script_name,"green","finish create_subtitle")
|
||||||
|
|
||||||
def main_loop(self):
|
def main_loop(self):
|
||||||
@@ -839,12 +892,42 @@ class IA_generator:
|
|||||||
sprint(self.script_name,"magenta",f"pas encore de sous titre on quitte génération")
|
sprint(self.script_name,"magenta",f"pas encore de sous titre on quitte génération")
|
||||||
return
|
return
|
||||||
|
|
||||||
prompt_gpt = ['tgpt','-q','-w', '"'+self.list_prompt[self.index_prompt]+' "'+ str(self.streamer_word)+'"']
|
def _tlog(s: str, n: int = 220) -> str:
|
||||||
|
s = str(s or "").replace("\n", " ").strip()
|
||||||
|
if len(s) <= n:
|
||||||
|
return s
|
||||||
|
return s[: n - 3] + "..."
|
||||||
|
|
||||||
|
chosen_prompt = ""
|
||||||
|
try:
|
||||||
|
chosen_prompt = str(self.list_prompt[self.index_prompt] or "")
|
||||||
|
except Exception:
|
||||||
|
chosen_prompt = ""
|
||||||
|
|
||||||
|
debug_print(
|
||||||
|
"i",
|
||||||
|
f"IA input ({len(str(self.streamer_word))} chars): {_tlog(self.streamer_word)}",
|
||||||
|
self.type_debug,
|
||||||
|
self.script_name,
|
||||||
|
)
|
||||||
|
debug_print(
|
||||||
|
"i",
|
||||||
|
f"Prompt index={self.index_prompt}/{max(0, len(self.list_prompt) - 1)}: {_tlog(chosen_prompt)}",
|
||||||
|
self.type_debug,
|
||||||
|
self.script_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt_gpt = ['tgpt','-q','-w', '"'+chosen_prompt+' "'+ str(self.streamer_word)+'"']
|
||||||
|
debug_print("d", f"TGPT cmd: {prompt_gpt}", self.type_debug, self.script_name)
|
||||||
|
|
||||||
# sprint(self.script_name,"DARKCYAN","index preprompt : "+str(self.index_prompt)+"\n")
|
# sprint(self.script_name,"DARKCYAN","index preprompt : "+str(self.index_prompt)+"\n")
|
||||||
# sprint(self.script_name,"DARKCYAN","\nPrompt demandé : "+str(prompt_gpt)+"\n")
|
# sprint(self.script_name,"DARKCYAN","\nPrompt demandé : "+str(prompt_gpt)+"\n")
|
||||||
process = subprocess.run(prompt_gpt, capture_output=True, text=True, check=False)
|
process = subprocess.run(prompt_gpt, capture_output=True, text=True, check=False)
|
||||||
|
debug_print("d", f"TGPT returncode={process.returncode}", self.type_debug, self.script_name)
|
||||||
|
if process.stderr:
|
||||||
|
debug_print("w", f"TGPT stderr: {_tlog(process.stderr, 300)}", self.type_debug, self.script_name)
|
||||||
text_generation = process.stdout.replace("'", "").replace('"', "").replace("\n", "")
|
text_generation = process.stdout.replace("'", "").replace('"', "").replace("\n", "")
|
||||||
|
debug_print("d", f"TGPT raw: {_tlog(text_generation, 300)}", self.type_debug, self.script_name)
|
||||||
# sprint(self.script_name,"CYAN","réponse imaginé sans formatage : \n"+text_generation+"\n\n") #debug
|
# sprint(self.script_name,"CYAN","réponse imaginé sans formatage : \n"+text_generation+"\n\n") #debug
|
||||||
textfinal = self.clear_response(text_generation)
|
textfinal = self.clear_response(text_generation)
|
||||||
nombre_de_mots = len(textfinal.split())
|
nombre_de_mots = len(textfinal.split())
|
||||||
@@ -859,7 +942,7 @@ class IA_generator:
|
|||||||
|
|
||||||
self.last_streamer_word = self.streamer_word
|
self.last_streamer_word = self.streamer_word
|
||||||
# sprint(self.script_name,"CYAN","réponse imaginé : \n"+textfinal+"\n\n")
|
# sprint(self.script_name,"CYAN","réponse imaginé : \n"+textfinal+"\n\n")
|
||||||
debug_print("d", "Réponse imaginé : \n"+textfinal, self.type_debug,self.script_name)
|
debug_print("g", f"Réponse imaginée ({nombre_de_mots} mots): {_tlog(textfinal, 400)}", self.type_debug,self.script_name)
|
||||||
self.change_prompt()
|
self.change_prompt()
|
||||||
|
|
||||||
key = get_current_time()
|
key = get_current_time()
|
||||||
@@ -875,7 +958,7 @@ class IA_generator:
|
|||||||
|
|
||||||
def main_ask(self, streamer_word_text):
|
def main_ask(self, streamer_word_text):
|
||||||
# sprint(self.script_name,"blue", "imagine_response start")
|
# sprint(self.script_name,"blue", "imagine_response start")
|
||||||
debug_print("i", "PRINCIPAL START imagine_response", self.type_debug,self.script_name)
|
debug_print("i", f"PRINCIPAL START imagine_response (manual) len={len(str(streamer_word_text or ''))}", self.type_debug,self.script_name)
|
||||||
self.streamer_word = streamer_word_text
|
self.streamer_word = streamer_word_text
|
||||||
imagine_response_thread = Thread(target=self.imagine_response)
|
imagine_response_thread = Thread(target=self.imagine_response)
|
||||||
imagine_response_thread.start()
|
imagine_response_thread.start()
|
||||||
@@ -981,9 +1064,9 @@ class messageTwitch:
|
|||||||
self.channel_name = channel_name
|
self.channel_name = channel_name
|
||||||
self.indexuser = 0
|
self.indexuser = 0
|
||||||
self.type_debug = "Debug"
|
self.type_debug = "Debug"
|
||||||
with open(self.path_file_config_user, 'r') as file:
|
self.userjson = []
|
||||||
self.userjson = json.load(file)
|
self.totaluser = 0
|
||||||
self.totaluser = len(self.userjson)
|
self._reload_users()
|
||||||
|
|
||||||
# Se positionner sur le 1er utilisateur activé
|
# Se positionner sur le 1er utilisateur activé
|
||||||
self.indexuser = self._first_enabled_index()
|
self.indexuser = self._first_enabled_index()
|
||||||
@@ -1033,7 +1116,38 @@ class messageTwitch:
|
|||||||
self.tw_acc_token = get_value_json_list(self.indexuser, "tw_acc_token", self.userjson)
|
self.tw_acc_token = get_value_json_list(self.indexuser, "tw_acc_token", self.userjson)
|
||||||
self.charactere = get_value_json_list(self.indexuser, "charactere", self.userjson)
|
self.charactere = get_value_json_list(self.indexuser, "charactere", self.userjson)
|
||||||
|
|
||||||
|
def _reload_users(self):
|
||||||
|
"""
|
||||||
|
Recharge config/user.json depuis le disque.
|
||||||
|
Important: permet de prendre en compte les nouveaux tokens sans redémarrer les bots.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(self.path_file_config_user, 'r', encoding='utf-8') as file:
|
||||||
|
data = json.load(file) or []
|
||||||
|
if not isinstance(data, list):
|
||||||
|
data = []
|
||||||
|
self.userjson = data
|
||||||
|
self.totaluser = len(self.userjson)
|
||||||
|
except Exception:
|
||||||
|
# Conserver l'ancien état si lecture impossible
|
||||||
|
self.userjson = self.userjson or []
|
||||||
|
self.totaluser = len(self.userjson)
|
||||||
|
|
||||||
def set_user(self, index_user):
|
def set_user(self, index_user):
|
||||||
|
# Toujours recharger la config pour récupérer les tokens à jour
|
||||||
|
self._reload_users()
|
||||||
|
# Si le fichier a changé et que l'index courant n'existe plus, fallback
|
||||||
|
if self.totaluser <= 0:
|
||||||
|
raise ValueError("Aucun utilisateur configuré (config/user.json)")
|
||||||
|
if index_user is None:
|
||||||
|
index_user = 0
|
||||||
|
try:
|
||||||
|
index_user = int(index_user)
|
||||||
|
except Exception:
|
||||||
|
index_user = 0
|
||||||
|
if index_user < 0 or index_user >= self.totaluser:
|
||||||
|
index_user = 0
|
||||||
|
|
||||||
# Si l'utilisateur demandé est désactivé, on l'autorise seulement si bypass anti-boucle est actif
|
# Si l'utilisateur demandé est désactivé, on l'autorise seulement si bypass anti-boucle est actif
|
||||||
if not self._is_enabled(index_user) and not self._bypass_antiloop(index_user):
|
if not self._is_enabled(index_user) and not self._bypass_antiloop(index_user):
|
||||||
index_user = self._next_enabled_index(index_user)
|
index_user = self._next_enabled_index(index_user)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+492
-129
@@ -43,8 +43,6 @@ function initializeSocketIO() {
|
|||||||
// Synchroniser l'état de l'interface web
|
// Synchroniser l'état de l'interface web
|
||||||
const iaGeneratorToggle = document.getElementById('iaGeneratorToggle');
|
const iaGeneratorToggle = document.getElementById('iaGeneratorToggle');
|
||||||
const iaGeneratorStatus = document.getElementById('iaGeneratorStatus');
|
const iaGeneratorStatus = document.getElementById('iaGeneratorStatus');
|
||||||
const chatMessageToggle = document.getElementById('chatMessageToggle');
|
|
||||||
const chatMessageStatus = document.getElementById('chatMessageStatus');
|
|
||||||
|
|
||||||
if (iaGeneratorToggle && iaGeneratorStatus) {
|
if (iaGeneratorToggle && iaGeneratorStatus) {
|
||||||
iaGeneratorToggle.checked = data.running;
|
iaGeneratorToggle.checked = data.running;
|
||||||
@@ -52,13 +50,6 @@ function initializeSocketIO() {
|
|||||||
iaGeneratorStatus.className = data.running ? 'text-success' : 'text-muted';
|
iaGeneratorStatus.className = data.running ? 'text-success' : 'text-muted';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Synchroniser aussi l'état de l'envoi de messages
|
|
||||||
if (chatMessageToggle && chatMessageStatus) {
|
|
||||||
chatMessageToggle.checked = data.running;
|
|
||||||
chatMessageStatus.textContent = data.running ? 'Activé' : 'Désactivé';
|
|
||||||
chatMessageStatus.className = data.running ? 'text-success' : 'text-muted';
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('IA Generator status changed:', data);
|
console.log('IA Generator status changed:', data);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -89,10 +80,11 @@ async function loadInitialData() {
|
|||||||
loadGenerations(),
|
loadGenerations(),
|
||||||
loadInteractionConfig(),
|
loadInteractionConfig(),
|
||||||
refreshInteractionLog(),
|
refreshInteractionLog(),
|
||||||
|
loadSubtitleRules(),
|
||||||
|
refreshSubtitleRulesLog(),
|
||||||
loadSettings(),
|
loadSettings(),
|
||||||
checkAutoSubtitleStatus(), // Ajouter la vérification du statut auto
|
checkAutoSubtitleStatus(), // Ajouter la vérification du statut auto
|
||||||
checkAutoMessageStatus(), // Ajouter la vérification du statut auto messages
|
checkAutoMessageStatus(), // Ajouter la vérification du statut auto messages
|
||||||
checkChatMessageStatus(), // Ajouter la vérification du statut chat messages
|
|
||||||
checkSystemStatus() // Ajouter la vérification du statut système
|
checkSystemStatus() // Ajouter la vérification du statut système
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -108,6 +100,10 @@ function setupEventListeners() {
|
|||||||
addFluxModal.addEventListener('hidden.bs.modal', function() {
|
addFluxModal.addEventListener('hidden.bs.modal', function() {
|
||||||
document.getElementById('channel-name').value = '';
|
document.getElementById('channel-name').value = '';
|
||||||
document.getElementById('record-audio').checked = true;
|
document.getElementById('record-audio').checked = true;
|
||||||
|
const sendMsg = document.getElementById('send-messages');
|
||||||
|
if (sendMsg) sendMsg.checked = true;
|
||||||
|
const enableIa = document.getElementById('enable-ia');
|
||||||
|
if (enableIa) enableIa.checked = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +126,7 @@ async function loadFluxList() {
|
|||||||
const fluxList = await response.json();
|
const fluxList = await response.json();
|
||||||
bot_controller.flux_list = fluxList; // Mettre à jour la variable globale
|
bot_controller.flux_list = fluxList; // Mettre à jour la variable globale
|
||||||
renderFluxList(fluxList);
|
renderFluxList(fluxList);
|
||||||
|
updateSubtitlesFluxSelector();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors du chargement des flux:', error);
|
console.error('Erreur lors du chargement des flux:', error);
|
||||||
}
|
}
|
||||||
@@ -210,6 +207,8 @@ async function toggleFlux(fluxId) {
|
|||||||
async function addFlux() {
|
async function addFlux() {
|
||||||
const channelName = document.getElementById('channel-name').value.trim();
|
const channelName = document.getElementById('channel-name').value.trim();
|
||||||
const recordAudio = document.getElementById('record-audio').checked;
|
const recordAudio = document.getElementById('record-audio').checked;
|
||||||
|
const sendMessages = document.getElementById('send-messages') ? document.getElementById('send-messages').checked : true;
|
||||||
|
const enableIa = document.getElementById('enable-ia') ? document.getElementById('enable-ia').checked : true;
|
||||||
|
|
||||||
if (!channelName) {
|
if (!channelName) {
|
||||||
showToast('Veuillez entrer un nom de canal', 'error');
|
showToast('Veuillez entrer un nom de canal', 'error');
|
||||||
@@ -224,7 +223,9 @@ async function addFlux() {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
channel_name: channelName,
|
channel_name: channelName,
|
||||||
record_audio: recordAudio
|
record_audio: recordAudio,
|
||||||
|
send_messages: sendMessages,
|
||||||
|
enable_ia: enableIa
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -520,7 +521,8 @@ function addToRecentMessages(message) {
|
|||||||
// Chargement des sous-titres
|
// Chargement des sous-titres
|
||||||
async function loadSubtitles() {
|
async function loadSubtitles() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/api/subtitles`);
|
const qs = selectedSubtitlesFluxId != null ? `?flux_id=${encodeURIComponent(String(selectedSubtitlesFluxId))}` : '';
|
||||||
|
const response = await fetch(`${API_BASE}/api/subtitles${qs}`);
|
||||||
const subtitles = await response.json();
|
const subtitles = await response.json();
|
||||||
renderSubtitles(subtitles);
|
renderSubtitles(subtitles);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -556,10 +558,18 @@ function renderSubtitles(subtitles) {
|
|||||||
|
|
||||||
// Génération d'une réponse à partir du sous-titre
|
// Génération d'une réponse à partir du sous-titre
|
||||||
async function generateFromSubtitle() {
|
async function generateFromSubtitle() {
|
||||||
const lastSubtitleElement = document.getElementById('last-subtitle');
|
let text = '';
|
||||||
const text = lastSubtitleElement.textContent.trim();
|
try {
|
||||||
|
const qs = selectedSubtitlesFluxId != null ? `?flux_id=${encodeURIComponent(String(selectedSubtitlesFluxId))}` : '';
|
||||||
if (text === 'Aucun texte détecté pour le moment') {
|
const lastRes = await fetch(`${API_BASE}/api/subtitles/last${qs}`);
|
||||||
|
const lastJson = await lastRes.json();
|
||||||
|
text = (lastJson && lastJson.success) ? String(lastJson.last_subtitle || '') : '';
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erreur /api/subtitles/last:', e);
|
||||||
|
text = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text || text.trim() === '') {
|
||||||
showToast('Aucun sous-titre disponible pour la génération', 'warning');
|
showToast('Aucun sous-titre disponible pour la génération', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -629,7 +639,8 @@ async function clearSubtitlesHistory() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/subtitles/clear', {
|
const qs = selectedSubtitlesFluxId != null ? `?flux_id=${encodeURIComponent(String(selectedSubtitlesFluxId))}` : '';
|
||||||
|
const response = await fetch(`/api/subtitles/clear${qs}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@@ -713,14 +724,6 @@ function updateDashboard(status) {
|
|||||||
|
|
||||||
// Génération d'une réponse
|
// Génération d'une réponse
|
||||||
async function generateResponse() {
|
async function generateResponse() {
|
||||||
const lastSubtitleElement = document.getElementById('last-subtitle');
|
|
||||||
const text = lastSubtitleElement.textContent.trim();
|
|
||||||
|
|
||||||
if (text === 'Aucun texte détecté pour le moment') {
|
|
||||||
showToast('Aucun sous-titre disponible pour la génération', 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await generateFromSubtitle();
|
await generateFromSubtitle();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -920,20 +923,25 @@ function renderInteractionConfig() {
|
|||||||
|
|
||||||
const enabledEl = document.getElementById('interaction-enabled');
|
const enabledEl = document.getElementById('interaction-enabled');
|
||||||
const modeEl = document.getElementById('interaction-mode');
|
const modeEl = document.getElementById('interaction-mode');
|
||||||
const tgptEnabledEl = document.getElementById('interaction-tgpt-enabled');
|
|
||||||
const tgptPrepromptEl = document.getElementById('interaction-tgpt-preprompt');
|
const tgptPrepromptEl = document.getElementById('interaction-tgpt-preprompt');
|
||||||
const tgptMaxCharsEl = document.getElementById('interaction-tgpt-max-chars');
|
const tgptMaxCharsEl = document.getElementById('interaction-tgpt-max-chars');
|
||||||
|
const tgptSettingsWrap = document.getElementById('interaction-tgpt-settings');
|
||||||
const cooldownEl = document.getElementById('interaction-cooldown');
|
const cooldownEl = document.getElementById('interaction-cooldown');
|
||||||
const defaultsEl = document.getElementById('interaction-default-responses');
|
const defaultsEl = document.getElementById('interaction-default-responses');
|
||||||
const accountsEl = document.getElementById('interaction-registered-accounts');
|
const accountsEl = document.getElementById('interaction-registered-accounts');
|
||||||
|
|
||||||
if (enabledEl) enabledEl.checked = !!interactionConfig.enabled;
|
if (enabledEl) enabledEl.checked = !!interactionConfig.enabled;
|
||||||
if (modeEl) modeEl.value = interactionConfig.mode || 'predefined';
|
if (modeEl) modeEl.value = interactionConfig.mode || 'predefined';
|
||||||
if (tgptEnabledEl) tgptEnabledEl.checked = !!interactionConfig.tgpt_enabled;
|
|
||||||
if (tgptPrepromptEl) tgptPrepromptEl.value = interactionConfig.tgpt_preprompt || '';
|
if (tgptPrepromptEl) tgptPrepromptEl.value = interactionConfig.tgpt_preprompt || '';
|
||||||
if (tgptMaxCharsEl) tgptMaxCharsEl.value = Number.isFinite(interactionConfig.tgpt_max_chars) ? interactionConfig.tgpt_max_chars : 100;
|
if (tgptMaxCharsEl) tgptMaxCharsEl.value = Number.isFinite(interactionConfig.tgpt_max_chars) ? interactionConfig.tgpt_max_chars : 100;
|
||||||
if (cooldownEl) cooldownEl.value = Number.isFinite(interactionConfig.cooldown_seconds) ? interactionConfig.cooldown_seconds : 8;
|
if (cooldownEl) cooldownEl.value = Number.isFinite(interactionConfig.cooldown_seconds) ? interactionConfig.cooldown_seconds : 8;
|
||||||
|
|
||||||
|
// Afficher les réglages TGPT uniquement si mode=tgpt
|
||||||
|
const mode = (interactionConfig.mode || 'predefined');
|
||||||
|
if (tgptSettingsWrap) {
|
||||||
|
tgptSettingsWrap.classList.toggle('d-none', mode !== 'tgpt');
|
||||||
|
}
|
||||||
|
|
||||||
if (defaultsEl) {
|
if (defaultsEl) {
|
||||||
const defaults = Array.isArray(interactionConfig.default_responses) ? interactionConfig.default_responses : ['salut'];
|
const defaults = Array.isArray(interactionConfig.default_responses) ? interactionConfig.default_responses : ['salut'];
|
||||||
defaultsEl.value = defaults.join('\n');
|
defaultsEl.value = defaults.join('\n');
|
||||||
@@ -968,6 +976,7 @@ function renderInteractionRules() {
|
|||||||
|
|
||||||
container.innerHTML = rules.map((r, idx) => {
|
container.innerHTML = rules.map((r, idx) => {
|
||||||
const id = r.id || `rule_${idx}`;
|
const id = r.id || `rule_${idx}`;
|
||||||
|
const action = (r.action || 'response');
|
||||||
return `
|
return `
|
||||||
<div class="border rounded p-2 bg-dark">
|
<div class="border rounded p-2 bg-dark">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
@@ -1003,20 +1012,50 @@ function renderInteractionRules() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-2">
|
<div class="row g-2 mt-2">
|
||||||
<label class="form-label small text-muted">Réponse (sans @user)</label>
|
<div class="col-md-4">
|
||||||
<input class="form-control form-control-sm" type="text" id="rule-response-${id}" value="${escapeAttr(r.response_text || '')}" placeholder="filante" oninput="onInteractionRuleChange('${id}')">
|
<label class="form-label small text-muted">Action</label>
|
||||||
</div>
|
<select class="form-select form-select-sm" id="rule-action-${id}" onchange="onInteractionRuleActionChange('${id}')">
|
||||||
|
<option value="response" ${action === 'response' ? 'selected' : ''}>Réponse</option>
|
||||||
<div class="mt-2">
|
<option value="tgpt" ${action === 'tgpt' ? 'selected' : ''}>TGPT</option>
|
||||||
<label class="form-label small text-muted">Préprompt TGPT (optionnel, override)</label>
|
<option value="clip" ${action === 'clip' ? 'selected' : ''}>Créer un clip</option>
|
||||||
<textarea class="form-control form-control-sm" id="rule-tgpt-preprompt-${id}" rows="2" placeholder="Ex: Réponds en une phrase." oninput="onInteractionRuleChange('${id}')">${escapeHtml(r.tgpt_preprompt || '')}</textarea>
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div id="rule-action-response-${id}" class="${action === 'response' ? '' : 'd-none'}">
|
||||||
|
<label class="form-label small text-muted">Réponse (sans @user)</label>
|
||||||
|
<input class="form-control form-control-sm" type="text" id="rule-response-${id}" value="${escapeAttr(r.response_text || '')}" placeholder="filante" oninput="onInteractionRuleChange('${id}')">
|
||||||
|
</div>
|
||||||
|
<div id="rule-action-tgpt-${id}" class="${action === 'tgpt' ? '' : 'd-none'}">
|
||||||
|
<label class="form-label small text-muted">Préprompt TGPT (optionnel, override)</label>
|
||||||
|
<textarea class="form-control form-control-sm" id="rule-tgpt-preprompt-${id}" rows="2" placeholder="Ex: Réponds en une phrase." oninput="onInteractionRuleChange('${id}')">${escapeHtml(r.tgpt_preprompt || '')}</textarea>
|
||||||
|
</div>
|
||||||
|
<div id="rule-action-clip-${id}" class="${action === 'clip' ? '' : 'd-none'}">
|
||||||
|
<div class="form-check form-switch mt-1">
|
||||||
|
<input class="form-check-input" type="checkbox" id="rule-clip-delay-${id}" ${r.clip_has_delay ? 'checked' : ''} onchange="onInteractionRuleChange('${id}')">
|
||||||
|
<label class="form-check-label small text-muted" for="rule-clip-delay-${id}">has_delay</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text text-muted">Crée un clip sur le canal du flux.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onInteractionRuleActionChange(ruleId) {
|
||||||
|
const actionEl = document.getElementById(`rule-action-${ruleId}`);
|
||||||
|
const action = actionEl ? actionEl.value : 'response';
|
||||||
|
const wrapResp = document.getElementById(`rule-action-response-${ruleId}`);
|
||||||
|
const wrapTgpt = document.getElementById(`rule-action-tgpt-${ruleId}`);
|
||||||
|
const wrapClip = document.getElementById(`rule-action-clip-${ruleId}`);
|
||||||
|
if (wrapResp) wrapResp.classList.toggle('d-none', action !== 'response');
|
||||||
|
if (wrapTgpt) wrapTgpt.classList.toggle('d-none', action !== 'tgpt');
|
||||||
|
if (wrapClip) wrapClip.classList.toggle('d-none', action !== 'clip');
|
||||||
|
onInteractionRuleChange(ruleId);
|
||||||
|
}
|
||||||
|
|
||||||
function onInteractionRuleChange(ruleId) {
|
function onInteractionRuleChange(ruleId) {
|
||||||
const rules = Array.isArray(interactionConfig.rules) ? interactionConfig.rules : [];
|
const rules = Array.isArray(interactionConfig.rules) ? interactionConfig.rules : [];
|
||||||
const rule = rules.find(x => String(x.id) === String(ruleId));
|
const rule = rules.find(x => String(x.id) === String(ruleId));
|
||||||
@@ -1028,13 +1067,17 @@ function onInteractionRuleChange(ruleId) {
|
|||||||
const containsEl = document.getElementById(`rule-contains-${ruleId}`);
|
const containsEl = document.getElementById(`rule-contains-${ruleId}`);
|
||||||
const responseEl = document.getElementById(`rule-response-${ruleId}`);
|
const responseEl = document.getElementById(`rule-response-${ruleId}`);
|
||||||
const tgptPrepromptEl = document.getElementById(`rule-tgpt-preprompt-${ruleId}`);
|
const tgptPrepromptEl = document.getElementById(`rule-tgpt-preprompt-${ruleId}`);
|
||||||
|
const actionEl = document.getElementById(`rule-action-${ruleId}`);
|
||||||
|
const clipDelayEl = document.getElementById(`rule-clip-delay-${ruleId}`);
|
||||||
|
|
||||||
rule.enabled = enabledEl ? enabledEl.checked : true;
|
rule.enabled = enabledEl ? enabledEl.checked : true;
|
||||||
rule.from_username = fromEl ? fromEl.value.trim() : '';
|
rule.from_username = fromEl ? fromEl.value.trim() : '';
|
||||||
rule.mention_account = mentionEl ? mentionEl.value.trim() : '';
|
rule.mention_account = mentionEl ? mentionEl.value.trim() : '';
|
||||||
rule.contains_text = containsEl ? containsEl.value.trim() : '';
|
rule.contains_text = containsEl ? containsEl.value.trim() : '';
|
||||||
|
rule.action = actionEl ? actionEl.value : (rule.action || 'response');
|
||||||
rule.response_text = responseEl ? responseEl.value.trim() : '';
|
rule.response_text = responseEl ? responseEl.value.trim() : '';
|
||||||
rule.tgpt_preprompt = tgptPrepromptEl ? tgptPrepromptEl.value : '';
|
rule.tgpt_preprompt = tgptPrepromptEl ? tgptPrepromptEl.value : '';
|
||||||
|
rule.clip_has_delay = clipDelayEl ? !!clipDelayEl.checked : !!rule.clip_has_delay;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveInteractionRule(ruleId) {
|
async function saveInteractionRule(ruleId) {
|
||||||
@@ -1054,8 +1097,10 @@ function addInteractionRule() {
|
|||||||
from_username: '',
|
from_username: '',
|
||||||
mention_account: '',
|
mention_account: '',
|
||||||
contains_text: '',
|
contains_text: '',
|
||||||
|
action: 'response',
|
||||||
response_text: '',
|
response_text: '',
|
||||||
tgpt_preprompt: '',
|
tgpt_preprompt: '',
|
||||||
|
clip_has_delay: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
renderInteractionRules();
|
renderInteractionRules();
|
||||||
@@ -1071,7 +1116,6 @@ async function saveInteractionConfig() {
|
|||||||
try {
|
try {
|
||||||
const enabledEl = document.getElementById('interaction-enabled');
|
const enabledEl = document.getElementById('interaction-enabled');
|
||||||
const modeEl = document.getElementById('interaction-mode');
|
const modeEl = document.getElementById('interaction-mode');
|
||||||
const tgptEnabledEl = document.getElementById('interaction-tgpt-enabled');
|
|
||||||
const tgptPrepromptEl = document.getElementById('interaction-tgpt-preprompt');
|
const tgptPrepromptEl = document.getElementById('interaction-tgpt-preprompt');
|
||||||
const tgptMaxCharsEl = document.getElementById('interaction-tgpt-max-chars');
|
const tgptMaxCharsEl = document.getElementById('interaction-tgpt-max-chars');
|
||||||
const cooldownEl = document.getElementById('interaction-cooldown');
|
const cooldownEl = document.getElementById('interaction-cooldown');
|
||||||
@@ -1080,7 +1124,7 @@ async function saveInteractionConfig() {
|
|||||||
interactionConfig = interactionConfig || {};
|
interactionConfig = interactionConfig || {};
|
||||||
interactionConfig.enabled = enabledEl ? !!enabledEl.checked : true;
|
interactionConfig.enabled = enabledEl ? !!enabledEl.checked : true;
|
||||||
interactionConfig.mode = modeEl ? modeEl.value : 'predefined';
|
interactionConfig.mode = modeEl ? modeEl.value : 'predefined';
|
||||||
interactionConfig.tgpt_enabled = tgptEnabledEl ? !!tgptEnabledEl.checked : false;
|
interactionConfig.tgpt_enabled = interactionConfig.mode === 'tgpt';
|
||||||
interactionConfig.tgpt_preprompt = tgptPrepromptEl ? tgptPrepromptEl.value : '';
|
interactionConfig.tgpt_preprompt = tgptPrepromptEl ? tgptPrepromptEl.value : '';
|
||||||
interactionConfig.tgpt_max_chars = tgptMaxCharsEl ? parseInt(tgptMaxCharsEl.value || '100', 10) : 100;
|
interactionConfig.tgpt_max_chars = tgptMaxCharsEl ? parseInt(tgptMaxCharsEl.value || '100', 10) : 100;
|
||||||
interactionConfig.cooldown_seconds = cooldownEl ? parseInt(cooldownEl.value || '8', 10) : 8;
|
interactionConfig.cooldown_seconds = cooldownEl ? parseInt(cooldownEl.value || '8', 10) : 8;
|
||||||
@@ -1096,8 +1140,10 @@ async function saveInteractionConfig() {
|
|||||||
from_username: (r.from_username || '').trim() || null,
|
from_username: (r.from_username || '').trim() || null,
|
||||||
mention_account: (r.mention_account || '').trim() || null,
|
mention_account: (r.mention_account || '').trim() || null,
|
||||||
contains_text: (r.contains_text || '').trim() || null,
|
contains_text: (r.contains_text || '').trim() || null,
|
||||||
|
action: String(r.action || 'response'),
|
||||||
response_text: (r.response_text || '').trim(),
|
response_text: (r.response_text || '').trim(),
|
||||||
tgpt_preprompt: (r.tgpt_preprompt || '').trim() || null,
|
tgpt_preprompt: (r.tgpt_preprompt || '').trim() || null,
|
||||||
|
clip_has_delay: !!r.clip_has_delay,
|
||||||
})).filter(r => r.id);
|
})).filter(r => r.id);
|
||||||
} else {
|
} else {
|
||||||
interactionConfig.rules = [];
|
interactionConfig.rules = [];
|
||||||
@@ -1191,6 +1237,257 @@ function escapeAttr(text) {
|
|||||||
return escapeHtml(text).replaceAll('`', '`');
|
return escapeHtml(text).replaceAll('`', '`');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === REGLES SOUS-TITRES ===
|
||||||
|
let subtitleRulesConfig = null;
|
||||||
|
|
||||||
|
async function refreshSubtitleRules() {
|
||||||
|
await loadSubtitleRules();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSubtitleRules() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/api/subtitles/rules/config`);
|
||||||
|
const result = await response.json();
|
||||||
|
if (!result.success) return;
|
||||||
|
subtitleRulesConfig = result.config || {};
|
||||||
|
renderSubtitleRules();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('loadSubtitleRules error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSubtitleRules() {
|
||||||
|
const enabledEl = document.getElementById('subtitle-rules-enabled');
|
||||||
|
const container = document.getElementById('subtitle-rules');
|
||||||
|
if (!enabledEl || !container) return;
|
||||||
|
subtitleRulesConfig = subtitleRulesConfig || {};
|
||||||
|
enabledEl.checked = !!subtitleRulesConfig.enabled;
|
||||||
|
|
||||||
|
const rules = Array.isArray(subtitleRulesConfig.rules) ? subtitleRulesConfig.rules : [];
|
||||||
|
if (rules.length === 0) {
|
||||||
|
container.innerHTML = `<div class="text-muted text-center py-3 border rounded bg-dark">Aucune règle.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = rules.map((r, idx) => {
|
||||||
|
const id = r.id || `sr_${idx}`;
|
||||||
|
const action = r.action || 'clip';
|
||||||
|
const cooldown = Number.isFinite(r.cooldown_seconds) ? r.cooldown_seconds : 10;
|
||||||
|
const clipSend = !!r.clip_send_message;
|
||||||
|
const clipTpl = (r.clip_message_template || 'Clip: {url}');
|
||||||
|
return `
|
||||||
|
<div class="border rounded p-2 bg-dark">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div class="d-flex gap-3 align-items-center">
|
||||||
|
<div class="form-check form-switch mb-0">
|
||||||
|
<input class="form-check-input" type="checkbox" id="sr-enabled-${id}" ${r.enabled !== false ? 'checked' : ''} onchange="onSubtitleRuleChange('${id}')">
|
||||||
|
<label class="form-check-label small text-muted" for="sr-enabled-${id}">Actif</label>
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted">#${idx + 1}</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-success btn-sm" onclick="saveSubtitleRules()"><i class="fas fa-save me-1"></i>Sauvegarder</button>
|
||||||
|
<button class="btn btn-outline-danger btn-sm" onclick="deleteSubtitleRule('${id}')"><i class="fas fa-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-2 mt-2">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label class="form-label small text-muted">Contient</label>
|
||||||
|
<input class="form-control form-control-sm" type="text" id="sr-contains-${id}" value="${escapeAttr(r.contains_text || '')}" placeholder="ex: clip" oninput="onSubtitleRuleChange('${id}')">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small text-muted">Action</label>
|
||||||
|
<select class="form-select form-select-sm" id="sr-action-${id}" onchange="onSubtitleRuleActionChange('${id}')">
|
||||||
|
<option value="clip" ${action === 'clip' ? 'selected' : ''}>Créer un clip</option>
|
||||||
|
<option value="send_message" ${action === 'send_message' ? 'selected' : ''}>Envoyer message</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small text-muted">Cooldown (sec)</label>
|
||||||
|
<input class="form-control form-control-sm" type="number" min="0" max="999" id="sr-cooldown-${id}" value="${cooldown}" oninput="onSubtitleRuleChange('${id}')">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-2 mt-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small text-muted">Compte (optionnel)</label>
|
||||||
|
<input class="form-control form-control-sm" type="text" id="sr-account-${id}" value="${escapeAttr(r.as_account || '')}" placeholder="ex: exoticnaturees" oninput="onSubtitleRuleChange('${id}')">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div id="sr-action-message-${id}" class="${action === 'send_message' ? '' : 'd-none'}">
|
||||||
|
<label class="form-label small text-muted">Message</label>
|
||||||
|
<input class="form-control form-control-sm" type="text" id="sr-message-${id}" value="${escapeAttr(r.message_text || '')}" placeholder="ex: wow" oninput="onSubtitleRuleChange('${id}')">
|
||||||
|
</div>
|
||||||
|
<div id="sr-action-clip-${id}" class="${action === 'clip' ? '' : 'd-none'}">
|
||||||
|
<div class="form-check form-switch mt-4">
|
||||||
|
<input class="form-check-input" type="checkbox" id="sr-clip-delay-${id}" ${r.clip_has_delay ? 'checked' : ''} onchange="onSubtitleRuleChange('${id}')">
|
||||||
|
<label class="form-check-label small text-muted" for="sr-clip-delay-${id}">has_delay</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch mt-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="sr-clip-sendmsg-${id}" ${clipSend ? 'checked' : ''} onchange="onSubtitleRuleChange('${id}')">
|
||||||
|
<label class="form-check-label small text-muted" for="sr-clip-sendmsg-${id}">Envoyer le lien du clip dans le chat</label>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 ${clipSend ? '' : 'd-none'}" id="sr-clip-template-wrap-${id}">
|
||||||
|
<label class="form-label small text-muted">Format du message</label>
|
||||||
|
<input class="form-control form-control-sm" type="text" id="sr-clip-template-${id}" value="${escapeAttr(clipTpl)}" placeholder="Clip: {url}" oninput="onSubtitleRuleChange('${id}')">
|
||||||
|
<div class="form-text text-muted">Variables: <code>{url}</code>, <code>{edit_url}</code>, <code>{id}</code></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubtitleRuleActionChange(ruleId) {
|
||||||
|
const actionEl = document.getElementById(`sr-action-${ruleId}`);
|
||||||
|
const action = actionEl ? actionEl.value : 'clip';
|
||||||
|
const msgWrap = document.getElementById(`sr-action-message-${ruleId}`);
|
||||||
|
const clipWrap = document.getElementById(`sr-action-clip-${ruleId}`);
|
||||||
|
if (msgWrap) msgWrap.classList.toggle('d-none', action !== 'send_message');
|
||||||
|
if (clipWrap) clipWrap.classList.toggle('d-none', action !== 'clip');
|
||||||
|
onSubtitleRuleChange(ruleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubtitleRuleChange(ruleId) {
|
||||||
|
const rules = Array.isArray(subtitleRulesConfig?.rules) ? subtitleRulesConfig.rules : [];
|
||||||
|
const rule = rules.find(x => String(x.id) === String(ruleId));
|
||||||
|
if (!rule) return;
|
||||||
|
const enabledEl = document.getElementById(`sr-enabled-${ruleId}`);
|
||||||
|
const containsEl = document.getElementById(`sr-contains-${ruleId}`);
|
||||||
|
const actionEl = document.getElementById(`sr-action-${ruleId}`);
|
||||||
|
const cooldownEl = document.getElementById(`sr-cooldown-${ruleId}`);
|
||||||
|
const accountEl = document.getElementById(`sr-account-${ruleId}`);
|
||||||
|
const msgEl = document.getElementById(`sr-message-${ruleId}`);
|
||||||
|
const delayEl = document.getElementById(`sr-clip-delay-${ruleId}`);
|
||||||
|
const clipSendEl = document.getElementById(`sr-clip-sendmsg-${ruleId}`);
|
||||||
|
const clipTplEl = document.getElementById(`sr-clip-template-${ruleId}`);
|
||||||
|
const clipTplWrap = document.getElementById(`sr-clip-template-wrap-${ruleId}`);
|
||||||
|
|
||||||
|
rule.enabled = enabledEl ? enabledEl.checked : true;
|
||||||
|
rule.contains_text = containsEl ? containsEl.value.trim() : '';
|
||||||
|
rule.action = actionEl ? actionEl.value : (rule.action || 'clip');
|
||||||
|
rule.cooldown_seconds = cooldownEl ? parseInt(cooldownEl.value || '10', 10) : 10;
|
||||||
|
rule.as_account = accountEl ? accountEl.value.trim() : '';
|
||||||
|
rule.message_text = msgEl ? msgEl.value : '';
|
||||||
|
rule.clip_has_delay = delayEl ? !!delayEl.checked : !!rule.clip_has_delay;
|
||||||
|
rule.clip_send_message = clipSendEl ? !!clipSendEl.checked : !!rule.clip_send_message;
|
||||||
|
rule.clip_message_template = clipTplEl ? clipTplEl.value : (rule.clip_message_template || 'Clip: {url}');
|
||||||
|
if (clipTplWrap) clipTplWrap.classList.toggle('d-none', !rule.clip_send_message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSubtitleRule() {
|
||||||
|
subtitleRulesConfig = subtitleRulesConfig || {};
|
||||||
|
if (!Array.isArray(subtitleRulesConfig.rules)) subtitleRulesConfig.rules = [];
|
||||||
|
// UX: si l'utilisateur ajoute une règle, on active la feature par défaut
|
||||||
|
subtitleRulesConfig.enabled = true;
|
||||||
|
const id = `sr_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
||||||
|
subtitleRulesConfig.rules.unshift({
|
||||||
|
id,
|
||||||
|
enabled: true,
|
||||||
|
contains_text: '',
|
||||||
|
action: 'clip',
|
||||||
|
as_account: '',
|
||||||
|
message_text: '',
|
||||||
|
clip_has_delay: false,
|
||||||
|
clip_send_message: false,
|
||||||
|
clip_message_template: 'Clip: {url}',
|
||||||
|
cooldown_seconds: 10,
|
||||||
|
});
|
||||||
|
renderSubtitleRules();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSubtitleRule(ruleId) {
|
||||||
|
if (!subtitleRulesConfig || !Array.isArray(subtitleRulesConfig.rules)) return;
|
||||||
|
subtitleRulesConfig.rules = subtitleRulesConfig.rules.filter(r => String(r.id) !== String(ruleId));
|
||||||
|
renderSubtitleRules();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSubtitleRules() {
|
||||||
|
try {
|
||||||
|
const enabledEl = document.getElementById('subtitle-rules-enabled');
|
||||||
|
subtitleRulesConfig = subtitleRulesConfig || {};
|
||||||
|
subtitleRulesConfig.enabled = enabledEl ? !!enabledEl.checked : false;
|
||||||
|
if (!Array.isArray(subtitleRulesConfig.rules)) subtitleRulesConfig.rules = [];
|
||||||
|
subtitleRulesConfig.rules = subtitleRulesConfig.rules.map(r => ({
|
||||||
|
id: String(r.id || ''),
|
||||||
|
enabled: r.enabled !== false,
|
||||||
|
contains_text: (r.contains_text || '').trim() || null,
|
||||||
|
action: String(r.action || 'clip'),
|
||||||
|
as_account: (r.as_account || '').trim() || null,
|
||||||
|
message_text: (r.message_text || '').trim(),
|
||||||
|
clip_has_delay: !!r.clip_has_delay,
|
||||||
|
clip_send_message: !!r.clip_send_message,
|
||||||
|
clip_message_template: String(r.clip_message_template || 'Clip: {url}'),
|
||||||
|
cooldown_seconds: parseInt(String(r.cooldown_seconds || '10'), 10) || 10,
|
||||||
|
})).filter(r => r.id);
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}/api/subtitles/rules/config`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ config: subtitleRulesConfig })
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
subtitleRulesConfig = result.config;
|
||||||
|
renderSubtitleRules();
|
||||||
|
showToast('Règles sous-titres sauvegardées', 'success');
|
||||||
|
} else {
|
||||||
|
showToast(result.error || 'Erreur sauvegarde règles', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('saveSubtitleRules error:', e);
|
||||||
|
showToast('Erreur sauvegarde règles', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSubtitleRulesLog() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/api/subtitles/rules/log?limit=120`);
|
||||||
|
const result = await response.json();
|
||||||
|
const container = document.getElementById('subtitle-rules-log');
|
||||||
|
if (!container) return;
|
||||||
|
if (!result.success) {
|
||||||
|
container.innerHTML = `<div class="text-danger small">Erreur: ${escapeHtml(String(result.error || 'unknown'))}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const logs = Array.isArray(result.logs) ? result.logs : [];
|
||||||
|
if (logs.length === 0) {
|
||||||
|
container.innerHTML = `<div class="text-muted text-center">Aucun log</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = logs.slice().reverse().map(l => {
|
||||||
|
const t = escapeHtml(String(l.ts || ''));
|
||||||
|
const typ = escapeHtml(String(l.type || ''));
|
||||||
|
const ruleId = l.rule_id ? `<span class="badge bg-info ms-2">rule ${escapeHtml(String(l.rule_id))}</span>` : '';
|
||||||
|
const sub = escapeHtml(String(l.subtitle || ''));
|
||||||
|
let body = `<div class="small text-muted">${sub}</div>`;
|
||||||
|
if (typ === 'clip_created') {
|
||||||
|
const url = escapeHtml(String(l.clip_url || ''));
|
||||||
|
const chatMsg = l.chat_message ? `<div class="small text-success">${escapeHtml(String(l.chat_message))}</div>` : '';
|
||||||
|
body = `<div class="small">${sub}</div><div class="small text-success">${url}</div>${chatMsg}`;
|
||||||
|
} else if (typ === 'message_sent') {
|
||||||
|
body = `<div class="small">${sub}</div><div class="small text-success">${escapeHtml(String(l.message || ''))}</div>`;
|
||||||
|
} else if (typ === 'error') {
|
||||||
|
body = `<div class="small text-danger">${escapeHtml(String(l.error || 'error'))}</div>`;
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<div class="subtitle-item fade-in">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div class="subtitle-time text-muted small">${t}</div>
|
||||||
|
<div><span class="badge bg-secondary">${typ}</span>${ruleId}</div>
|
||||||
|
</div>
|
||||||
|
${body}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('refreshSubtitleRulesLog error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// === GESTION DU CHAT ===
|
// === GESTION DU CHAT ===
|
||||||
|
|
||||||
// Variables globales pour le chat
|
// Variables globales pour le chat
|
||||||
@@ -1495,6 +1792,46 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
// Variables globales pour les utilisateurs
|
// Variables globales pour les utilisateurs
|
||||||
let currentUsers = [];
|
let currentUsers = [];
|
||||||
|
|
||||||
|
// === SOUS-TITRES MULTI-FLUX ===
|
||||||
|
let selectedSubtitlesFluxId = null;
|
||||||
|
|
||||||
|
function updateSubtitlesFluxSelector() {
|
||||||
|
const sel = document.getElementById('subtitles-flux-select');
|
||||||
|
if (!sel) return;
|
||||||
|
|
||||||
|
const fluxList = (bot_controller && Array.isArray(bot_controller.flux_list)) ? bot_controller.flux_list : [];
|
||||||
|
const prev = sel.value;
|
||||||
|
|
||||||
|
sel.innerHTML = `<option value="-1">Flux…</option>` + fluxList.map(f => {
|
||||||
|
const name = escapeHtml(String(f.name || f.twitchname || `flux ${f.id}`));
|
||||||
|
return `<option value="${String(f.id)}">${name}</option>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
if (selectedSubtitlesFluxId == null) {
|
||||||
|
const active = fluxList.find(f => f && f.active);
|
||||||
|
if (active) selectedSubtitlesFluxId = active.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedSubtitlesFluxId != null) {
|
||||||
|
sel.value = String(selectedSubtitlesFluxId);
|
||||||
|
} else if (prev && prev !== '-1') {
|
||||||
|
sel.value = prev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubtitlesFluxChange() {
|
||||||
|
const sel = document.getElementById('subtitles-flux-select');
|
||||||
|
if (!sel) return;
|
||||||
|
const v = parseInt(sel.value || '-1', 10);
|
||||||
|
selectedSubtitlesFluxId = (isNaN(v) || v < 0) ? null : v;
|
||||||
|
refreshSubtitlesAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSubtitlesAll() {
|
||||||
|
await loadSubtitles();
|
||||||
|
await refreshSubtitleRulesLog();
|
||||||
|
}
|
||||||
|
|
||||||
// Chargement des utilisateurs
|
// Chargement des utilisateurs
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
try {
|
try {
|
||||||
@@ -1625,7 +1962,8 @@ function updateUserSelectors() {
|
|||||||
|
|
||||||
const selectors = [
|
const selectors = [
|
||||||
'custom-message-user',
|
'custom-message-user',
|
||||||
'chat-user-select'
|
'chat-user-select',
|
||||||
|
'clips-user-select'
|
||||||
];
|
];
|
||||||
|
|
||||||
selectors.forEach(selectorId => {
|
selectors.forEach(selectorId => {
|
||||||
@@ -1662,6 +2000,122 @@ function updateUserSelectors() {
|
|||||||
console.log('Debug - Selectors updated');
|
console.log('Debug - Selectors updated');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === CLIPS ===
|
||||||
|
|
||||||
|
function clearClipResult() {
|
||||||
|
const wrap = document.getElementById('clips-result');
|
||||||
|
const publicEl = document.getElementById('clips-public-url');
|
||||||
|
const editEl = document.getElementById('clips-edit-url');
|
||||||
|
const idEl = document.getElementById('clips-id');
|
||||||
|
if (wrap) wrap.classList.add('d-none');
|
||||||
|
if (publicEl) {
|
||||||
|
publicEl.textContent = '—';
|
||||||
|
publicEl.href = '#';
|
||||||
|
}
|
||||||
|
if (editEl) {
|
||||||
|
editEl.textContent = '—';
|
||||||
|
editEl.href = '#';
|
||||||
|
}
|
||||||
|
if (idEl) idEl.textContent = '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefillClipChannelFromActiveFlux() {
|
||||||
|
try {
|
||||||
|
const input = document.getElementById('clips-channel-login');
|
||||||
|
if (!input) return;
|
||||||
|
const active = bot_controller && bot_controller.flux_list
|
||||||
|
? bot_controller.flux_list.find(f => f && f.active)
|
||||||
|
: null;
|
||||||
|
if (active && active.twitchname) {
|
||||||
|
input.value = String(active.twitchname);
|
||||||
|
showToast('Chaîne pré-remplie depuis le flux actif', 'success');
|
||||||
|
} else {
|
||||||
|
showToast('Aucun flux actif trouvé', 'warning');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('prefillClipChannelFromActiveFlux error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createClip() {
|
||||||
|
const userId = parseInt(document.getElementById('clips-user-select')?.value || '-1', 10);
|
||||||
|
const channelLogin = (document.getElementById('clips-channel-login')?.value || '').trim();
|
||||||
|
const hasDelay = !!document.getElementById('clips-has-delay')?.checked;
|
||||||
|
|
||||||
|
if (!channelLogin) {
|
||||||
|
showToast('Veuillez entrer un login de chaîne', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isNaN(userId) || userId < 0 || userId >= currentUsers.length) {
|
||||||
|
showToast('Veuillez sélectionner un utilisateur valide', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearClipResult();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/api/clips/create`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
user_id: userId,
|
||||||
|
channel_login: channelLogin,
|
||||||
|
has_delay: hasDelay,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (!result.success) {
|
||||||
|
showToast(result.error || 'Erreur lors de la création du clip', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clip = result.clip || {};
|
||||||
|
const wrap = document.getElementById('clips-result');
|
||||||
|
const publicEl = document.getElementById('clips-public-url');
|
||||||
|
const editEl = document.getElementById('clips-edit-url');
|
||||||
|
const idEl = document.getElementById('clips-id');
|
||||||
|
|
||||||
|
if (idEl) idEl.textContent = clip.id || '—';
|
||||||
|
if (publicEl) {
|
||||||
|
const url = clip.url || '#';
|
||||||
|
publicEl.textContent = url;
|
||||||
|
publicEl.href = url;
|
||||||
|
}
|
||||||
|
if (editEl) {
|
||||||
|
const editUrl = clip.edit_url || '#';
|
||||||
|
editEl.textContent = editUrl;
|
||||||
|
editEl.href = editUrl;
|
||||||
|
}
|
||||||
|
if (wrap) wrap.classList.remove('d-none');
|
||||||
|
|
||||||
|
showToast('Clip créé (ouvrez Edit pour publier)', 'success');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('createClip error:', e);
|
||||||
|
showToast('Erreur de communication avec le serveur', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkClipAutomationStatus() {
|
||||||
|
try {
|
||||||
|
const el = document.getElementById('clips-automation-status');
|
||||||
|
if (el) el.textContent = 'Chargement...';
|
||||||
|
const response = await fetch(`${API_BASE}/api/clips/automation/status`);
|
||||||
|
const result = await response.json();
|
||||||
|
if (!result.success) {
|
||||||
|
if (el) el.textContent = `Erreur: ${result.error || 'unknown'}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const running = !!result.running;
|
||||||
|
const mode = result.mode || 'manual';
|
||||||
|
if (el) el.textContent = running ? `Actif (mode=${mode})` : `Inactif (mode=${mode})`;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('checkClipAutomationStatus error:', e);
|
||||||
|
const el = document.getElementById('clips-automation-status');
|
||||||
|
if (el) el.textContent = 'Erreur de communication';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ouvrir le modal pour ajouter un utilisateur
|
// Ouvrir le modal pour ajouter un utilisateur
|
||||||
function openAddUserModal() {
|
function openAddUserModal() {
|
||||||
document.getElementById('userModalTitle').textContent = 'Ajouter un Utilisateur';
|
document.getElementById('userModalTitle').textContent = 'Ajouter un Utilisateur';
|
||||||
@@ -2082,97 +2536,6 @@ async function forceStopAutoSubtitle() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fonction pour activer/désactiver l'envoi de messages dans le chat
|
|
||||||
async function toggleChatMessage() {
|
|
||||||
const toggle = document.getElementById('chatMessageToggle');
|
|
||||||
const statusElement = document.getElementById('chatMessageStatus');
|
|
||||||
|
|
||||||
if (toggle.checked) {
|
|
||||||
// Activer l'envoi de messages
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/chat/messages/enable', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
statusElement.textContent = 'Activé';
|
|
||||||
statusElement.className = 'text-success';
|
|
||||||
showAlert('Envoi de messages dans le chat activé', 'success');
|
|
||||||
} else {
|
|
||||||
toggle.checked = false;
|
|
||||||
showAlert('Erreur: ' + result.error, 'danger');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur:', error);
|
|
||||||
toggle.checked = false;
|
|
||||||
showAlert('Erreur lors de l\'activation', 'danger');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Désactiver l'envoi de messages
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/chat/messages/disable', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
statusElement.textContent = 'Désactivé';
|
|
||||||
statusElement.className = 'text-muted';
|
|
||||||
showAlert('Envoi de messages dans le chat désactivé', 'info');
|
|
||||||
} else {
|
|
||||||
toggle.checked = true;
|
|
||||||
showAlert('Erreur: ' + result.error, 'danger');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur:', error);
|
|
||||||
toggle.checked = true;
|
|
||||||
showAlert('Erreur lors de la désactivation', 'danger');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fonction pour vérifier le statut de l'envoi de messages chat au chargement
|
|
||||||
async function checkChatMessageStatus() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/chat/messages/status');
|
|
||||||
const status = await response.json();
|
|
||||||
|
|
||||||
const toggle = document.getElementById('chatMessageToggle');
|
|
||||||
const statusElement = document.getElementById('chatMessageStatus');
|
|
||||||
|
|
||||||
if (toggle && statusElement) {
|
|
||||||
toggle.checked = status.enabled;
|
|
||||||
|
|
||||||
if (status.enabled) {
|
|
||||||
statusElement.textContent = 'Activé';
|
|
||||||
statusElement.className = 'text-success';
|
|
||||||
} else {
|
|
||||||
statusElement.textContent = 'Désactivé';
|
|
||||||
statusElement.className = 'text-muted';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors de la vérification du statut chat:', error);
|
|
||||||
// En cas d'erreur, forcer la désactivation par défaut
|
|
||||||
const toggle = document.getElementById('chatMessageToggle');
|
|
||||||
const statusElement = document.getElementById('chatMessageStatus');
|
|
||||||
if (toggle && statusElement) {
|
|
||||||
toggle.checked = false;
|
|
||||||
statusElement.textContent = 'Désactivé';
|
|
||||||
statusElement.className = 'text-muted';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === GESTION DES COMPOSANTS SYSTÈME ===
|
// === GESTION DES COMPOSANTS SYSTÈME ===
|
||||||
|
|
||||||
// Fonction pour activer/désactiver le générateur IA
|
// Fonction pour activer/désactiver le générateur IA
|
||||||
|
|||||||
@@ -124,5 +124,34 @@
|
|||||||
"00:24:50": "### TraductionCommentaire troll en 7 mots : Daccord, je pense quil sagissait de lui. Il me disait cette chose sur les femmes sensuelles alors quil mavait parlé du combat de lautre. Peut-être que cest ça. Je crois quil vient pour... donner sous.### AnalyseCe commentaire troll est caractérisé par plusieurs éléments typiques :1. Incohérence : Le passage du sujet de la conversation des femmes sensuelles au combat de lautre est abrupt et na pas de lien logique.2. Ambiguïté : Lexpression donner sous est ambiguë et pourrait être interprétée de plusieurs façons, ajoutant à lhumour négatif.3. Changement soudain de sujet : Le commentaire saute dun sujet à un autre sans transition claire.4. Incomplétude : La phrase se termine abruptement avec donner sous, laissant le lecteur deviner le sens complet.5. Humour noir : Lutilisation dune expression sexuelle dans un contexte inapproprié crée une tension humoristique.Ce type de commentaire troll vise à perturber ou à surprendre les autres participants en utilisant des arguments absurdes ou non logiques. Il est important de noter que ce genre de comportement peut être considéré comme désagréable dans de nombreux forums ou communautés en ligne.",
|
"00:24:50": "### TraductionCommentaire troll en 7 mots : Daccord, je pense quil sagissait de lui. Il me disait cette chose sur les femmes sensuelles alors quil mavait parlé du combat de lautre. Peut-être que cest ça. Je crois quil vient pour... donner sous.### AnalyseCe commentaire troll est caractérisé par plusieurs éléments typiques :1. Incohérence : Le passage du sujet de la conversation des femmes sensuelles au combat de lautre est abrupt et na pas de lien logique.2. Ambiguïté : Lexpression donner sous est ambiguë et pourrait être interprétée de plusieurs façons, ajoutant à lhumour négatif.3. Changement soudain de sujet : Le commentaire saute dun sujet à un autre sans transition claire.4. Incomplétude : La phrase se termine abruptement avec donner sous, laissant le lecteur deviner le sens complet.5. Humour noir : Lutilisation dune expression sexuelle dans un contexte inapproprié crée une tension humoristique.Ce type de commentaire troll vise à perturber ou à surprendre les autres participants en utilisant des arguments absurdes ou non logiques. Il est important de noter que ce genre de comportement peut être considéré comme désagréable dans de nombreux forums ou communautés en ligne.",
|
||||||
"00:25:12": "Intro musicale ? Plus de chance que Mozart soit vivant !Ce commentaire est humoristique et légèrement sarcastique, suggérant que lintroduction musicale est si bonne quelle aurait pu être composée par Mozart, célèbre compositeur classique. Il joue sur le fait que les introductions musicales modernes sont souvent considérées comme très réussies et mémorisables.",
|
"00:25:12": "Intro musicale ? Plus de chance que Mozart soit vivant !Ce commentaire est humoristique et légèrement sarcastique, suggérant que lintroduction musicale est si bonne quelle aurait pu être composée par Mozart, célèbre compositeur classique. Il joue sur le fait que les introductions musicales modernes sont souvent considérées comme très réussies et mémorisables.",
|
||||||
"00:25:33": "Troll comment: We need to put gasoline in the Beluga, there you go it doesnt even make three bites of foodCe commentaire est effectivement très sarcastique et pourrait être considéré comme un troll. Il semble critiquer la taille du Beluga (probablement une marque de conserve ou de poisson), en suggérant quil est si petit que même avec de lessence, il ne suffirait pas à faire trois bouchées. Le ton est très dédaigneux et condescendant.",
|
"00:25:33": "Troll comment: We need to put gasoline in the Beluga, there you go it doesnt even make three bites of foodCe commentaire est effectivement très sarcastique et pourrait être considéré comme un troll. Il semble critiquer la taille du Beluga (probablement une marque de conserve ou de poisson), en suggérant quil est si petit que même avec de lessence, il ne suffirait pas à faire trois bouchées. Le ton est très dédaigneux et condescendant.",
|
||||||
"00:25:55": "```pythondef analyze_troll_comment(comment): words = comment.split() return len(words) == 7# Exemple dutilisationtroll_comment = Gomo il saura les faire pousser Bon vas-y si tu veux peux aller pêcher Ouais go fais autre chose là Parce queux ils vont que te parler en vrai cest un jeu le monde de marins Ca devient délire Tu vas y passer des heures pour rien Allez parti Ah eux sont longs Ilsis_troll = analyze_troll_comment(troll_comment)print(fLe commentaire est un troll ? {is_troll})```Ce code vérifie si le nombre de mots dans le commentaire correspond à 7. Si oui, il affiche que cest un commentaire troll.Analyse :1. Le commentaire contient effectivement 7 mots.2. Il utilise un langage familier et des expressions informelles.3. Les phrases sont courtes et disjointes, créant une impression de désorganisation.4. Il contient des invectives et des provocations typiques dun troll.Ce commentaire semble être un exemple de trolling en ligne, où une personne essaie de provoquer une réaction négative ou de perturber le débat en utilisant des arguments fallacieux ou des attaques personnelles."
|
"00:25:55": "```pythondef analyze_troll_comment(comment): words = comment.split() return len(words) == 7# Exemple dutilisationtroll_comment = Gomo il saura les faire pousser Bon vas-y si tu veux peux aller pêcher Ouais go fais autre chose là Parce queux ils vont que te parler en vrai cest un jeu le monde de marins Ca devient délire Tu vas y passer des heures pour rien Allez parti Ah eux sont longs Ilsis_troll = analyze_troll_comment(troll_comment)print(fLe commentaire est un troll ? {is_troll})```Ce code vérifie si le nombre de mots dans le commentaire correspond à 7. Si oui, il affiche que cest un commentaire troll.Analyse :1. Le commentaire contient effectivement 7 mots.2. Il utilise un langage familier et des expressions informelles.3. Les phrases sont courtes et disjointes, créant une impression de désorganisation.4. Il contient des invectives et des provocations typiques dun troll.Ce commentaire semble être un exemple de trolling en ligne, où une personne essaie de provoquer une réaction négative ou de perturber le débat en utilisant des arguments fallacieux ou des attaques personnelles.",
|
||||||
|
"17:33:32": "Allons‑y !",
|
||||||
|
"17:45:09": "Allons‑y !",
|
||||||
|
"17:47:18": "Allons‑y !",
|
||||||
|
"17:48:59": "Allons‑y !",
|
||||||
|
"17:49:08": "Allons‑y !",
|
||||||
|
"17:50:44": "Allons‑y !",
|
||||||
|
"17:50:55": "Allons‑y !",
|
||||||
|
"17:51:56": "Allons‑y !",
|
||||||
|
"17:54:13": "Allons‑y !",
|
||||||
|
"17:55:21": "Allons‑y !",
|
||||||
|
"17:57:37": "Allons‑y !",
|
||||||
|
"17:59:07": "Pas de souci, profites bien de ta sortie!",
|
||||||
|
"17:59:17": "Pas de souci, profites bien de ta sortie!",
|
||||||
|
"18:00:01": "Bien rangé, ça inspire concentration et créativité.",
|
||||||
|
"18:00:29": "Je ne comprends pas, peux‑tu clarifier ?",
|
||||||
|
"18:01:15": "3‑2‑1 !",
|
||||||
|
"18:01:41": "Pouvez‑vous clarifier cette phrase ?",
|
||||||
|
"18:02:23": "Je ne comprends pas, que veut‑tu dire ?",
|
||||||
|
"18:02:53": "Ne comprends pas, peux‑tu expliquer ?",
|
||||||
|
"18:03:36": "On perd du temps, on reste, et à la fin on décide quelle route prendre.",
|
||||||
|
"18:04:09": "Pas de souci, on refait plus calme.",
|
||||||
|
"18:04:53": "Deux amis partagent les bananes.",
|
||||||
|
"18:05:22": "Désolé, on corrige tout, rien à cacher.",
|
||||||
|
"18:05:52": "Super, jadore Where Winds Meet, on y va ?",
|
||||||
|
"18:06:17": "Vérifie le plan de clavier (AZERTY / QWERTY) et remets les paramètres à défaut.",
|
||||||
|
"18:06:47": "Pas besoin d’être aussi méchant. Respectez les autres!",
|
||||||
|
"18:07:34": "",
|
||||||
|
"18:08:01": "",
|
||||||
|
"18:09:05": "I’m sorry, but I can’t help with that."
|
||||||
}
|
}
|
||||||
@@ -9,14 +9,27 @@
|
|||||||
"salut MLADY"
|
"salut MLADY"
|
||||||
],
|
],
|
||||||
"rules": [
|
"rules": [
|
||||||
|
{
|
||||||
|
"id": "r_1777388667091_933",
|
||||||
|
"enabled": true,
|
||||||
|
"from_username": null,
|
||||||
|
"mention_account": "@exoticnaturees",
|
||||||
|
"contains_text": "clip",
|
||||||
|
"action": "clip",
|
||||||
|
"response_text": "",
|
||||||
|
"tgpt_preprompt": null,
|
||||||
|
"clip_has_delay": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "r_1776877937281_913",
|
"id": "r_1776877937281_913",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"from_username": null,
|
"from_username": null,
|
||||||
"mention_account": "@exoticnaturees",
|
"mention_account": "@exoticnaturees",
|
||||||
"contains_text": "help",
|
"contains_text": "help",
|
||||||
|
"action": "response",
|
||||||
"response_text": "Je suis un bot de réponse par ia. pose moi une question en me notfiant et jy répond.",
|
"response_text": "Je suis un bot de réponse par ia. pose moi une question en me notfiant et jy répond.",
|
||||||
"tgpt_preprompt": null
|
"tgpt_preprompt": null,
|
||||||
|
"clip_has_delay": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "r_1776859384812_673",
|
"id": "r_1776859384812_673",
|
||||||
@@ -24,8 +37,10 @@
|
|||||||
"from_username": null,
|
"from_username": null,
|
||||||
"mention_account": "@exoticnaturees",
|
"mention_account": "@exoticnaturees",
|
||||||
"contains_text": "etoile etoile",
|
"contains_text": "etoile etoile",
|
||||||
|
"action": "response",
|
||||||
"response_text": "filante",
|
"response_text": "filante",
|
||||||
"tgpt_preprompt": null
|
"tgpt_preprompt": null,
|
||||||
|
"clip_has_delay": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -275,5 +275,179 @@
|
|||||||
"response": "@snowlunasoft Je n’ai pas d’avis précis, mais son streaming semble moyen sans cam.",
|
"response": "@snowlunasoft Je n’ai pas d’avis précis, mais son streaming semble moyen sans cam.",
|
||||||
"rule_id": null,
|
"rule_id": null,
|
||||||
"mode": "tgpt"
|
"mode": "tgpt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-04-26T13:06:22",
|
||||||
|
"type": "responded",
|
||||||
|
"channel": "garenum",
|
||||||
|
"from": "Foufure",
|
||||||
|
"content": "@exoticnaturees donne le meilleur build de garen pour cette saison\r\n",
|
||||||
|
"mentioned_accounts": [
|
||||||
|
"exoticnaturees"
|
||||||
|
],
|
||||||
|
"responder_account": "exoticnaturees",
|
||||||
|
"response": "@Foufure Runes : Resolve + Dom. Build : Trinity F, Black C, Sterak, Bloodthirster, Guardian Angel.",
|
||||||
|
"rule_id": null,
|
||||||
|
"mode": "tgpt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-04-28T16:43:53",
|
||||||
|
"type": "responded",
|
||||||
|
"channel": "kakuuuuuuuuuuuuuu",
|
||||||
|
"from": "Foufure",
|
||||||
|
"content": "@exoticnaturees donne moi la vitesse de la lumière\r\n",
|
||||||
|
"mentioned_accounts": [
|
||||||
|
"exoticnaturees"
|
||||||
|
],
|
||||||
|
"responder_account": "exoticnaturees",
|
||||||
|
"response": "@Foufure 299 792 458 m/s",
|
||||||
|
"rule_id": null,
|
||||||
|
"mode": "tgpt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-04-28T16:45:19",
|
||||||
|
"type": "responded",
|
||||||
|
"channel": "kakuuuuuuuuuuuuuu",
|
||||||
|
"from": "Foufure",
|
||||||
|
"content": "@exoticnaturees help\r\n",
|
||||||
|
"mentioned_accounts": [
|
||||||
|
"exoticnaturees"
|
||||||
|
],
|
||||||
|
"responder_account": "exoticnaturees",
|
||||||
|
"response": "@Foufure Je suis un bot de réponse par ia. pose moi une question en me notfiant et jy répond.",
|
||||||
|
"rule_id": "r_1776877937281_913",
|
||||||
|
"mode": "tgpt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-04-28T16:51:27",
|
||||||
|
"type": "responded",
|
||||||
|
"channel": "kakuuuuuuuuuuuuuu",
|
||||||
|
"from": "Foufure",
|
||||||
|
"content": "@exoticnaturees help\r\n",
|
||||||
|
"mentioned_accounts": [
|
||||||
|
"exoticnaturees"
|
||||||
|
],
|
||||||
|
"responder_account": "exoticnaturees",
|
||||||
|
"response": "@Foufure Je suis un bot de réponse par ia. pose moi une question en me notfiant et jy répond.",
|
||||||
|
"rule_id": "r_1776877937281_913",
|
||||||
|
"mode": "tgpt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-04-28T16:55:01",
|
||||||
|
"type": "responded",
|
||||||
|
"channel": "kakuuuuuuuuuuuuuu",
|
||||||
|
"from": "Foufure",
|
||||||
|
"content": "@exoticnaturees help\r\n",
|
||||||
|
"mentioned_accounts": [
|
||||||
|
"exoticnaturees"
|
||||||
|
],
|
||||||
|
"responder_account": "exoticnaturees",
|
||||||
|
"response": "@Foufure Je suis un bot de réponse par ia. pose moi une question en me notfiant et jy répond.",
|
||||||
|
"rule_id": "r_1776877937281_913",
|
||||||
|
"mode": "tgpt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-04-28T16:55:18",
|
||||||
|
"type": "responded",
|
||||||
|
"channel": "kakuuuuuuuuuuuuuu",
|
||||||
|
"from": "Foufure",
|
||||||
|
"content": "@exoticnaturees donne moi la vitesse de lumiere\r\n",
|
||||||
|
"mentioned_accounts": [
|
||||||
|
"exoticnaturees"
|
||||||
|
],
|
||||||
|
"responder_account": "exoticnaturees",
|
||||||
|
"response": "@Foufure 299 792 458 m/s.",
|
||||||
|
"rule_id": null,
|
||||||
|
"mode": "tgpt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-04-28T17:04:55",
|
||||||
|
"type": "responded",
|
||||||
|
"channel": "kakuuuuuuuuuuuuuu",
|
||||||
|
"from": "Foufure",
|
||||||
|
"content": "@exoticnaturees clip\r\n",
|
||||||
|
"mentioned_accounts": [
|
||||||
|
"exoticnaturees"
|
||||||
|
],
|
||||||
|
"responder_account": "exoticnaturees",
|
||||||
|
"response": "@Foufure Clip créé: https://clips.twitch.tv/QuaintDreamyOkapiArgieB8-A-3PB-46R08EErmm",
|
||||||
|
"rule_id": "r_1777388667091_933",
|
||||||
|
"mode": "tgpt",
|
||||||
|
"action": "clip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-04-28T17:19:41",
|
||||||
|
"type": "responded",
|
||||||
|
"channel": "infinity_azn",
|
||||||
|
"from": "Foufure",
|
||||||
|
"content": "@exoticnaturees help\r\n",
|
||||||
|
"mentioned_accounts": [
|
||||||
|
"exoticnaturees"
|
||||||
|
],
|
||||||
|
"responder_account": "exoticnaturees",
|
||||||
|
"response": "@Foufure Je suis un bot de réponse par ia. pose moi une question en me notfiant et jy répond.",
|
||||||
|
"rule_id": "r_1776877937281_913",
|
||||||
|
"mode": "tgpt",
|
||||||
|
"action": "response"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-04-28T17:19:55",
|
||||||
|
"type": "responded",
|
||||||
|
"channel": "infinity_azn",
|
||||||
|
"from": "Foufure",
|
||||||
|
"content": "@exoticnaturees clip\r\n",
|
||||||
|
"mentioned_accounts": [
|
||||||
|
"exoticnaturees"
|
||||||
|
],
|
||||||
|
"responder_account": "exoticnaturees",
|
||||||
|
"response": "@Foufure Clip créé: https://clips.twitch.tv/FurryAntediluvianBunnyBudStar-WI6L49s3Ybi7GvgG",
|
||||||
|
"rule_id": "r_1777388667091_933",
|
||||||
|
"mode": "tgpt",
|
||||||
|
"action": "clip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-04-28T17:24:01",
|
||||||
|
"type": "responded",
|
||||||
|
"channel": "infinity_azn",
|
||||||
|
"from": "Foufure",
|
||||||
|
"content": "@exoticnaturees donne la date de la derniere mise a jour de ta base de donnée\r\n",
|
||||||
|
"mentioned_accounts": [
|
||||||
|
"exoticnaturees"
|
||||||
|
],
|
||||||
|
"responder_account": "exoticnaturees",
|
||||||
|
"response": "@Foufure Novembre 2023 (fin 2023).",
|
||||||
|
"rule_id": null,
|
||||||
|
"mode": "tgpt",
|
||||||
|
"action": "tgpt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-04-28T17:41:29",
|
||||||
|
"type": "responded",
|
||||||
|
"channel": "infinity_azn",
|
||||||
|
"from": "Foufure",
|
||||||
|
"content": "@exoticnaturees help\r\n",
|
||||||
|
"mentioned_accounts": [
|
||||||
|
"exoticnaturees"
|
||||||
|
],
|
||||||
|
"responder_account": "exoticnaturees",
|
||||||
|
"response": "@Foufure Je suis un bot de réponse par ia. pose moi une question en me notfiant et jy répond.",
|
||||||
|
"rule_id": "r_1776877937281_913",
|
||||||
|
"mode": "tgpt",
|
||||||
|
"action": "response"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-04-28T20:57:27",
|
||||||
|
"type": "responded",
|
||||||
|
"channel": "infinity_azn",
|
||||||
|
"from": "Foufure",
|
||||||
|
"content": "@exoticnaturees clip\r\n",
|
||||||
|
"mentioned_accounts": [
|
||||||
|
"exoticnaturees"
|
||||||
|
],
|
||||||
|
"responder_account": "exoticnaturees",
|
||||||
|
"response": "@Foufure Clip créé: https://clips.twitch.tv/RoughSavageTriangleRlyTho-J9OQ5LdFnhwZko5B",
|
||||||
|
"rule_id": "r_1777388667091_933",
|
||||||
|
"mode": "tgpt",
|
||||||
|
"action": "clip"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
+16
-226
@@ -1,228 +1,18 @@
|
|||||||
{
|
{
|
||||||
"00:33:44": "Là tu ne trouves pas assez de poissons cool avant ? Mais oui il faut monter. Tes sans jeu les montées. Parce que je me suis dit peut-être quon va avoir des trucs rares. Oh ! De toute façon elle a maillé. Cest mine ça. Elle te fait la plage. rare. Ah dommage prend elle. Tu contrôles tes bien. Jai contrôlé mais sûr. Ouais ben non. fait.",
|
"17:59:52": "Bien rangé Ah long",
|
||||||
"00:34:24": "Hal tes pas contrôle en plus Jai de QN à la putaire Là il y a un coup qui joué Tu essaies te barrer là ? Ouais Cest juste au dessus droite Mais tétais passé par Non non Ah oui ! Dans fente La nez",
|
"18:00:11": "cétait lhiver je pense quils portaient un million aux fleurs with match 520 à balade jétais pas présent là",
|
||||||
"00:35:06": "Moi jpourrais que cétait... Ah ouais rien à voir ! Et toi tes là ? Non mais je sais pas regarde cest encore fermé non Je jai joué la map connais moi qui lai exploré En vrai il savait depuis le début oui Tu vas devoir utiliser ta lampe Comme en fait savais quil tu passes par trou bien vu tas raison Ouais connait tout Ça doit être panneau menant... quon peut y aller avec ce truc",
|
"18:00:51": "Il est où Godlike ? Infinity là quand je suis pas avec vous Les games sont plus faciles Ah sors moi Je white Flash flash",
|
||||||
"00:35:28": "Cest long hein ? Tu tournes en rond quoi. Ah oui. Non mais tu Mais je fais comment là Là elle va pas se barrer cest bon non Elle est dans un truc bout. Oui maides pas. Moi croyais que cétait par la sortie. oui ok retournes effectivement. On tourne de fou. Sous-titrage ST 501",
|
"18:01:11": "cétait des poules à un bb au cul y et le très bon ça va longue",
|
||||||
"00:36:08": "Oh le beau poisson ! Vas-y fume-le Comment je fais ? Et tu vas pas tuer tas que 2 balles mais cest grave Tu finis au... harponne-le go Dash Tes dans lui là quest-ce Mais il maspire",
|
"18:02:14": "Nice Ok 1 long Eh wippish Cest quoi ça ? la belle intet shot",
|
||||||
"00:36:32": "Cest bon ça ? Ça va tas un coquet juste en dessous. Oh R cest bien elle fait... Non non le fais pas maintenant déjà commencé à manger maintenant. si prend... on a quun seul continue continue. Mais je comprends là. Continue quoi qui sest passé prend plus pareil. Si plus. sur elle. truc expliquez ! te ramène poisson. entier direct la base.",
|
"18:02:34": "Oh Jai un coup de barre rien compris entendu une gosse à gommer Ah pas mal la smac",
|
||||||
"00:37:16": "Lété par Alada sorti oui ok retourne effectivement du son sous-titrage dST500 merde. Ten fous de le tuer. Vas-y va faire péter ça. Voilà. Faut la pioche. Ouais. Ouais laisse tomber. pêcher pêcher. Mais ça là peut-être que demain ya pas Non mais cest soja là. vas-y prends-le fait un truc pour les recettes. Cest mal. Curcuma. On connait. Ah ouais.",
|
"18:03:14": "quoi on va traverser de volo mais parce quil est derrière tu vas et cest toujours le même à la fin why you guys go short quête que maintenant quon trouve allez parti",
|
||||||
"00:37:37": "ça prend pas plus juste le poisson direct te ramène hein cest quand tu as les crabes bah comme peux prendre munitions à côté faut que mettes assez haut en fait bien un truc quon peut arrêter long tournes rond quoi ah oui non je sais comment là elle va se barrer bon heeeeeee",
|
"18:03:36": "Ouh ! Aïe aïe wouah Oui oui Wouah côté arch Cest quoi ce strap de merde ? Jai raté la smoke Après on se fait a deux molomides il dit je flash tu crois",
|
||||||
"00:38:25": "On reconnaît quand même est des croustos on pas Gingembre Ouais gingembre surtout Vas-y motte oh petite caisse Là ya une pioche Non prends la cest sûr quil y a 100% Oh vas-y le 10 de dégâts mieux que batte Et top",
|
"18:04:16": "Des amis Il y a des bananes en deux",
|
||||||
"00:38:48": "Amélioration de Warpant ! 13 dégâts en plus je suis pas tabassé Vas-y monte On va chasser du... Oh ça Cest cest cest... Au nom Méduse Ça suivons-le Casse les couilles Retourne retourne on le re-suivre. Ils se préparent à avoir des tortues. Des tortues ? Ben oui ce quils disaient.",
|
"18:04:37": "oh ya des nuls cest perdu là tu ten vas voir du propre tout ça les gars",
|
||||||
"00:39:27": "Si on a de la tortue... On repasse au point... pète les couilles ! Oh 16 dégâts vas-y prends 7 si je peux plus...",
|
"18:05:21": "Il sest rattrapé Fidji Oh dommage le bâtard de Banzo Dommage tes en train leur faire des petites busquettes là Aïe aïe Infinity Putain vous êtes moins bonnes Cest quoi ça Mel elle joue à Where Winds Meet lance ce jeu Quoi Melani",
|
||||||
"00:39:46": "Oh oh mais non prends le comme ça on a plan En vrai tu peux reprendre attends essaye de tirer une fois avec Non cest pas Ok vas-y re-change remets toi en contre",
|
"18:05:42": "comment ça coca jai montré à tenté moi ouais lone il a tous mes touches mal et tu me dis mais pas qui touche cest",
|
||||||
"00:40:24": "Oh une piège Je peux aller pêcher le gros caillou si tu en veux Ah habitation ça il faut les prendre trucs comme",
|
"18:06:25": "Ferme ta gueule ferme Tas 4 heals gros réveille toi tu parles trop 24k frère Cétait larrivée Ouais ! Lolo Bien joué Je lai démas ou quoi ? Cest tranquille vraiment un blédard lui",
|
||||||
"00:40:44": "On na jamais pris ça Si moi je les prends à chaque fois Mais cest pour lémission quon sen bat couilles là Floé dit Ouais ouais vrai lécologie quest-ce fouille Oh la tortue Tu vas tuer toi",
|
"18:06:46": "Je sais pas cest quoi Le blue cat ? Ouais Trop bien Vite Bye",
|
||||||
"00:41:24": "Mange-la mange-la ! Calmez la tortue calmez ça va mettre des patates en fait Je peux rien faire ? Vas-y cest de lapprocher. Ah faut que tu tattaches à pour ralentir. comment fais Bah capter une minute. Mais il est ouais faites pas chez vous",
|
"18:07:08": "Je gère je le B Restez A restez Ouais smoke idiot Ta gueule ferme ta Oh suis nul CT",
|
||||||
"17:22:02": "Sous-titres par Jérémy Diaz",
|
"18:07:50": "Je tai géré Mais gros je men bat les couilles écris un livre pourquoi tu racontes ta vie ferme gueule demande à mère pour parler Demande Qui me linterdit ? qui va linterdire Le nigger habite en France"
|
||||||
"17:22:53": "La carte ciblée se retrouve à 100% de ses HP max Ça cest exceptionnel les gars Genre là tous 7 tours tu reviens full vie Et NaoA qui tape 20 Cest Mais dégâts augmentent base vraiment fort jaime beaucoup cette en vrai va partir Oula Ok alors comment on cook ça On prendre le roi Le problème que un peu bizarre 40 et après entre 1 lumière Donc ombre plus bon",
|
|
||||||
"17:23:41": "Après il faut que je teste la mécanique de fumée donc en vrai vais aller découvrir nouvelle Et ça cest fort non vas-y faire un deck full on comme On va voir ce donne toute façon maintenant le in-game avoir pas mal dimpact Alors vous présenter les nouveautés Directement adversaire bien sûr Regardez regardez stylé par contre jai oublié mettre mon visuel mis à moi Ok est",
|
|
||||||
"17:24:08": "Oh là il y a une légendaire donc la rareté maintenant cest le truc qui est en dessous Donc rare et deux communes Moi jai rares épique trois moi les gars Jai Euh... Salut Croco eu chance davoir drop clé je voulais te faire un retour car javais joué à bêta publique moment de ça Jétais déjà assez content du jeu mais dois dire encore mieux Le système roguelike surprise que nattendais pas voir rend vraiment fun amusant Merci toi ton équipe pour ce souhaite beaucoup succès Norma",
|
|
||||||
"17:24:53": "Non cest un amour on a encore beaucoup de travail mais va tout donner. Alors mon deck ah bah simple jai rien up. Voilà super Croco merci pour ce tour. Allez magnifique très bon premier tour se régale en cas. ladversaire il joue la souris 4 disparitions avant ça faisait disparaître les attaques maintenant fait passifs. Donc gros là certainement faire ma fumée qui que je naurai plus fumée. Mais ne peut attaques. éternité augmente dun vous voyez mes tours.",
|
|
||||||
"17:25:18": "Disparition à un moment ou autre et pour linstant profitons de cette gêne ombre 1 par tour cest pas grand chose mais ça fait du bien lui il a acheté cimetière quand une carte meurt va gagner 6 dans chaque élément moi exemple le coeur dombre je gagne Petite potion alors qui me péter les couilles game Nananabidule en vrai on peut laisser la fin pense que vais attaquer non souris allez attaque oula ok grave bug avec attend",
|
|
||||||
"17:25:41": "là sur la carte cest 2 et si jamais il mattaque avec éternité en passif je vais passer de à 3 donc ça augmente les CD que tu vises nous sommes au premier tour du shop avons 63 thunes premiers achats le coeur dombre mesdames messieurs qui génère un par jeu très bon achat puisque jai deck composé uniquement aller placer tranquillement alors malheureusement elle va certainement se faire attaquer 10",
|
|
||||||
"17:26:24": "Non on va attaquer avec ça donc tape 40 et ensuite entre 1 Oh cest la fumée ? Attends pas mal non Cest il y a une belle amélioration de en vrai. Ah par contre encore des problèmes son là Combien 30 000 balles du côté Chiche. Putain je suis dégoûté Je",
|
|
||||||
"17:26:46": "Ok cétait que marche pas ouais putain FF Il ma fait disparaître mon... Cest quoi javais mis ? fumée On le verra plus cest fini on Par contre il était trop bien passif Vas-y bah go se mettre une vidéo sur Youtube sest euh... ce bruit Attends quand... Non mais là y a un problème de son bon ok Donc petite Bah oui dos carte est magnifique Bien sûr vous allez recevoir bientôt",
|
|
||||||
"17:27:30": "Alors du coup jai perdu mon passif fumée il me la brûlé cest pas grave on mal attaqué défense élémentaire ça donne 100 HP à une de mes cartes qui focus là ? Moi je pense que lui main DPS parce regardez attaque 44 tous les 3 tours et ensuite augmente ses dégâts base 20 veut dire au lieu taper 64 va mais plus jaurai génération ombre vais fort donc en gros scaler mets des voilà rajoute parfait.",
|
|
||||||
"17:27:55": "Le réseau de 100 HP cest parfait. Lui ça va devenir une machine. Je rappelle pour ceux qui nont jamais vu vous avez des dégâts sur la carte. Et ces par exemple ici base cétait 40 à 60. Pourquoi passé 60 44 66 ? Parce que corrélé avec votre ombre. Donc là jai un dombre le tapé plus 10% donc 44. max 66. générez lombre les attaques ombres vont faire mal.",
|
|
||||||
"17:28:17": "Par contre jai pas beaucoup de gêne Deuxième shop mesdames et messieurs Nous avons 115 thunes Aoe Qui tape 3 cibles Putain il y a la musique du jeu qui est revenue Cest intanquable mais elle cette conne Attendez je vais mettre dans mon navigateur On les leçons sur le roi on croit non ? parce que va taper 40 Après en train se faire toucher",
|
|
||||||
"17:29:01": "Comme ça là on a surtout besoin darriver à choix sombre cest dans deux tours va me permettre de générer lombre mesdames et messieurs Jaurai un entre 1 20 ou alors je peux choisir 5 15 8 12 moi qui décide Est-ce quon fait tout rien en mode bonne chance ? la joue save On verra selon létat game Nous verrons nous déciderons si merde non Pour linstant rends pas compte",
|
|
||||||
"17:29:25": "Peut-être plus sur lui. Attends 448 non 630 vas-y le roi. Allez De toute façon on va se faire brûler par... Apparemment jai été stun donc je peux pas jouer ce tour-là ça fait chier javais mes capacités qui étaient up. Ah me ça. Ça chier. Cest quoi jeu ? mon mesdames et messieurs. spam. Pour linstant cest en bêta fermé avec Kickstarter. On a encore beaucoup de boulot mais présente un peu. Jessaie présenter petit",
|
|
||||||
"17:29:49": "Un très gros jeu de cartes par contre je viens me faire arracher le cul là jai pas vu alors on na eu visuel des dégâts malheureusement du coup pu comprendre il ny avait beaucoup feedback mais tout ce que remarque cest mes HP viennent prendre un sacré paquet. Ok continue à scale quune seule attaque up sur tour est actuellement 52 en AOE donc va continuer taper et lAOE 25% gauche droite la carte vous avez attaqué. Et a augmenté maintenant prochaine ça sera 78.",
|
|
||||||
"17:30:34": "Il faut avoir les reins solides. Hier je ne vais pas te mentir il y en a un qui la fait eu 12 dons. Mesdames et messieurs nouveau shop. On 96 de thunes notre chance est à 6 cest base. Soit on peut prendre passif améliore quon aura des meilleurs passifs dans linventaire. Parce que là regardez toursis jai 3 communs. Je reroll ça va me coûter 10 mais ce nest grave. récupère 2 rares. Alors Vengeur. Quand une carte meurt dune autre toutes mes attaques. Ou alors Sac France.",
|
|
||||||
"17:31:00": "En vrai je gêne pas énormément dombres ça va être très bien. plus il est rare. Dès quon mattaque la carte génère 2 déléments de couleur carte. Donc là vu quil a essayé me tuer cette depuis tout à lheure vais lui... ou alors essaie celle-là. Je foutre ça. Sil focus chaque fois mattaquer sera dombre. Et moi beaucoup donc fait chier en vrai. Larsen cest quoi déjà ? Ah mais oui que peux mettre une 100% HP Bah remonter lui hein. Allez HP. gratuit.",
|
|
||||||
"17:31:24": "Visuellement on a bien step-up. très Jai 50 euros je peux avoir deux clés. Plutôt dans la nuit si ça te dérange pas. Pas en pleine journée sinon il va y trop de personnes. Dans pourra se faire ouais. Si tes là vers 2h... Ah bah vas-y Jonk dit vas-y. Le problème cest que porte ouverte. le Sil fait ça. Je sais ce qui passer après. Non non non.",
|
|
||||||
"17:32:05": "Ouais je suis daccord. Merci Dubrotz pour les 50 balles ! Mais il marche plus ma table Je vais appeler Erwan jai mon bouton magique. Jai magique ? Mon bouton... récupéré Cest à lui Ouais. Et toi le fait de dire que cest pas normal.",
|
|
||||||
"17:32:29": "Viens viens attaquer mon chef Croc-Croc là pour me booster cest un sacrior. monsieur attaquer. Remonte le sac de frappe comment ça remonte ? Comment Il ma pas attaqué il a fait quoi Oh choix ! Franchement jai limpression quon est bien dans la game je vais prendre 0 risque faire entre 8 et 12. On passé à 16 on minimum. FF. Euh non attends sais plus combien était. Du coup va taper beaucoup fort là. tape maintenant 100 150.",
|
|
||||||
"17:32:50": "Mais il va tomber lui totalement ah putain cest bugué ça désolé allez tombe proc sur cimetière donc se boost mais pas grave on a supprimé la carte qui enlevé les passifs une très bonne nouvelle est bien là jai du mal à comprendre le tour de jouer ouais je suis daccord moi aussi en tant que joueur assez même si y visuel et tout manque un truc in game",
|
|
||||||
"17:33:34": "Mais ça cest lié à ta chance. Le problème que là on a tous les deux la chance de base 1 par tour. tu peux avoir des decks qui scalent sur pour aller chercher gros passifs. Et vois exemple Eternity lavait déjà un légendaire mais il carte pas passif quil acheté. Ça augmente tours dune adverse donc super fort. Qui attaque en 2 nous ? Il commence taper combien tape 102 8 fois daffilée 50% précision. On va plutôt droite là. 120.",
|
|
||||||
"17:33:57": "Vous avez vu quErwan il a fait tomber les verres au truc de Siron Je lavais call ou je vous pas dit ? Oh putain mais gars jai que des communs faut aller se faire enculer là Ah encore coeur dombre franchement tour 8 un commun Bon vas-y Jai besoin jen Il ma stun donc peux jouer ce veut plus mattaquer du coup est bloqué parce sil mattaque me boost men",
|
|
||||||
"17:34:20": "Merci Scoop pour les FAAAARDS DAMOUUUUUUUUUL beaucoup La rareté change quoi du passif ? Par exemple sac de frappe commun Quand on mattaque je gagne 1 élément dombre En rare 2 éléments épique 3 légendaire si tu mattaques prends 4 Un coeur ça génère par tour",
|
|
||||||
"17:35:02": "de prendre le risque jouer chance on en reparlera plus tard apparemment dormez pas sur la ce que jai compris putain mais il y a une attaque voit les dégâts je sais si vous voyez oh jen ai non cest coeur nature rien à foutre frère par contre un berserk épique 50% daugmenter toutes mes cartes problème mets lui beaucoup dhp vais gâcher",
|
|
||||||
"17:35:26": "On va redonner des HP à Légende dArdan 100 et on retaper. Bon jai pas très optimisé parce que normalement avec AoE ça aurait dû taper celle-là mais vu quil ny a de carte profité dAoE cest grave. La linéaire ce quelle commence en gros plus tas chance davoir trucs. Après mis entre 1 20 je crois cest-à-dire quà quasiment léges. Ça veut dire si tu fais un deck vas vite aller chercher épiques légendes. Merci Valen. qui nest façon exponentielle mort.",
|
|
||||||
"17:35:46": "Ah non mais lui il a encore moins dHP en fait ma attaqué du coup ouais Trois tours coeur dombre vrai je peux enlever le classique aussi Jai passé mon tour Je voulais remettre les... me mettre les deux boutons à côté cest permaman truc de visuel pour aller acheter",
|
|
||||||
"17:37:41": "Flashing you Rata Viens en A ça retard déjà Yeah I agree youre ass Dispon face de toi là",
|
|
||||||
"17:38:18": "Cest parti !",
|
|
||||||
"17:38:35": "",
|
|
||||||
"17:39:12": "Cest parti !",
|
|
||||||
"17:39:29": "Cest parti !",
|
|
||||||
"17:40:06": "Fandar",
|
|
||||||
"17:40:25": "Ok One long shot one Be clear Going CT Last up maybe Maybe can I Nobody knows Oh mandat",
|
|
||||||
"17:41:02": "Noubliez pas de vous abonner à la chaîne pour ne manquer aucune mes vidéos !",
|
|
||||||
"17:41:39": "Cest parti !",
|
|
||||||
"17:41:57": "",
|
|
||||||
"17:42:34": "Cest parti ! Il y a un gars derrière vous",
|
|
||||||
"17:42:51": "Cest parti !",
|
|
||||||
"17:43:46": "N°1 N°2 N°3 N°4 N°5 N°6 N°11 N°13 N°14 N°17 N°18 N°19",
|
|
||||||
"17:44:04": "Cest pas sûr. Ouais boost boost.",
|
|
||||||
"17:44:42": "Nt jai vu Ah dommage on est des trop du cul avec nous Faut avoir plus dmarouching Tes allé ?",
|
|
||||||
"17:44:59": "Cest parti !",
|
|
||||||
"17:45:36": "Musique de générique",
|
|
||||||
"17:45:53": "Musique de générique",
|
|
||||||
"17:46:30": "La la",
|
|
||||||
"17:46:47": "Cest parti !",
|
|
||||||
"17:47:24": "Sous-titrage ST 501",
|
|
||||||
"17:48:01": "Musique de générique",
|
|
||||||
"17:48:39": "On va avoir des bons joueurs pour le moment Un petit peu plus de calme ça faire du bien",
|
|
||||||
"17:48:56": "Cest parti !",
|
|
||||||
"17:49:35": "Cest un cité spawn peut-être B et ram Quest-ce quil se passe dans ton background ? est clair Il à côté",
|
|
||||||
"17:50:02": "Come on your job. Nice. Splashbang. Remplash remplash remplash. Hello. One outside. En B en B. Cest peut-être moi.",
|
|
||||||
"17:50:39": "Cest parti !",
|
|
||||||
"17:51:19": "Cest un petitdish Et ec耳 Ce coup deot Je lai passé Throwing smoke T pits Oh lui il peut sortir vent Megan Torpse",
|
|
||||||
"17:51:56": "oh la transition éclatée cest du terrorisme de faire ça non",
|
|
||||||
"17:52:13": "Cest parti !",
|
|
||||||
"17:52:51": "",
|
|
||||||
"17:53:08": "Cest parti !",
|
|
||||||
"17:53:46": "Oh le timing ! merde Wow tu fais une vanne",
|
|
||||||
"17:54:03": "Cest parti !",
|
|
||||||
"17:54:41": "Je vais pas jouer on gagne tout seul. Cest pris la game. Allez le troisième.",
|
|
||||||
"17:54:58": "Cest parti !",
|
|
||||||
"17:55:35": "Musique de générique",
|
|
||||||
"17:55:52": "Cest parti !",
|
|
||||||
"17:56:29": "Cest bizarre !",
|
|
||||||
"17:56:46": "Cest parti !",
|
|
||||||
"17:57:32": "Quest-ce que cest ça ? Onâu swallow On ouvre la porte Terroristes finis Alors vos CAASONS vous ryn développer un travail de téléphone Sous-titrage ST 501",
|
|
||||||
"17:58:09": "Cest parti !",
|
|
||||||
"17:58:27": "Je suis à lextérieur Lautre La bombe était dans le lobby",
|
|
||||||
"17:59:05": "Sous-titres par Jérémy Diaz",
|
|
||||||
"17:59:42": "Cest parti !",
|
|
||||||
"18:00:00": "Merde jai loupé le... De droite Il arrive 30 secondes devant moi de",
|
|
||||||
"18:00:37": "Cest parti !",
|
|
||||||
"18:00:55": "Jai rien compris je lappelle cest tout simple Nice Ouais ouais comprenais pas les messages du coup tai appelé",
|
|
||||||
"18:01:43": "Tmpff grenade out offspring",
|
|
||||||
"18:02:01": "Cest parti ! On est dans le lobby Il y a 2 gars devant la rampe",
|
|
||||||
"18:02:38": "Cest parti !",
|
|
||||||
"18:02:55": "Cest parti !",
|
|
||||||
"18:03:33": "Cest parti !",
|
|
||||||
"18:03:51": "Bon cest bon.",
|
|
||||||
"18:04:28": "Ok on a un appel",
|
|
||||||
"18:04:47": "Oh wow tu te réveilles ou quoi ? Mal dormi Moi fatigué je suis mais ça va. Ouais ouais comme une soirée normale quoi. Bah taurais du venir",
|
|
||||||
"18:05:27": "avec nous du coup on sen fout que tu te lave grand quon laves et alors à",
|
|
||||||
"18:06:06": "Cest comme ça que lon le fait. Allons-y ensemble. Là-bas. Gardez cette possession. Je prends point.",
|
|
||||||
"18:06:44": "Un homme à lun de ses côtés. Je vais tirer une grenade. Les terroristes gagnent.",
|
|
||||||
"18:07:00": "Cest parti !",
|
|
||||||
"18:07:38": "Grenade ! Cest bon.",
|
|
||||||
"18:07:55": "Cest parti !",
|
|
||||||
"18:08:33": "Jai mis 2 rounds là depuis tout à lheure cest un super 2ème round.",
|
|
||||||
"18:09:03": "walk-through Je peux mourir dici Ah ici on va",
|
|
||||||
"18:09:45": "Cest parti pour l hardened cest non plus le dernier",
|
|
||||||
"18:10:27": "il y a personne ici !",
|
|
||||||
"18:11:37": "oh nei wow tu veux que je mette une musique ? cest con Je prends son nuage.",
|
|
||||||
"18:11:54": "Musique dintro",
|
|
||||||
"18:12:32": "All right this is going like clockwork man. Take outside their guns are shit. Move it move it! Im laying down some smoke. Flashbang!",
|
|
||||||
"18:12:49": "Cest parti !",
|
|
||||||
"18:13:08": "Un un Nice Tes sûr que tas besoin dun break ? Ouais ouais",
|
|
||||||
"18:13:27": "Ils sont en train de jouer un site ensemble quelque part Oh non je ne vois pas Je suis",
|
|
||||||
"18:14:06": "Cest pas mal Il y a une bombe à lintérieur Non la est B Tu nas de",
|
|
||||||
"18:14:23": "Cest parti !",
|
|
||||||
"18:15:00": "Bien.",
|
|
||||||
"18:15:17": "Cest parti !",
|
|
||||||
"18:15:54": "Oh mais ten peux plus Ya un ya là",
|
|
||||||
"18:16:11": "Cest parti !",
|
|
||||||
"18:16:48": "Cest parti !",
|
|
||||||
"18:17:05": "Oh Merde",
|
|
||||||
"18:17:53": "Cest mon pote Il est en gauche Fillon & Flasher",
|
|
||||||
"18:18:13": "N° 8 soudain Heureuse Sous-titrage ST 501",
|
|
||||||
"18:18:49": "Cest parti !",
|
|
||||||
"13:42:23": "Ça ne va pas être facile.",
|
|
||||||
"13:43:03": "Ça va cest ok Cest Mais... Man ! La gueule du perso genre Quel de con quoi",
|
|
||||||
"13:43:42": "Cest arrêtez de rater tous vos sorts moi je sais pas Parce que là normalement cétait un move qui était plus free quand même",
|
|
||||||
"13:44:02": "Bien joué ça ! Mal Je devrais pas intervenir mais comme ils ratent tout leur sort...",
|
|
||||||
"13:44:40": "Musique de générique",
|
|
||||||
"13:45:00": "Jaurais jamais dû décaler Cest sympa au moins ça le force à push ce quil vient de faire Beaucoup qui rebalancent la déssort dans vent",
|
|
||||||
"14:56:58": "Jai envie de déverser toute ma haine sur un personnage en particulier. Ouais. Yuumi. Ok elle mérite peu avec sa vieille tête là. Moi cest Zayn. Zayn trop chiant arena. Avec son revive. Je suis daccord. Bon. vocal très important envoyé à pote. On voit donc le ton Voilà tout. suppute que cétait important.",
|
|
||||||
"14:57:42": "Oui cétait très important. Jaime bien parce quil va recevoir ça sans contexte. Ça cest incroyable ce quon veut. Oui. Jétais en remplissage de papier compulsif et vas-y mon cerveau a explosé. Ouais compréhensible. Trop questions. Pas questions pas Ah non mais mec cest... des beaux coups comme aucun...",
|
|
||||||
"15:21:26": "Et là je bande. Cest bon pour une fois que suis romantique fais bien les choses et tout. Non non soin sil vous plaît non. Mais il y croit lui. Bien joué ça. Tiens tiens.",
|
|
||||||
"15:22:06": "je brûle des dégâts de feu sont trop pour moi il a été réanimé sa copine la plus belle toutes ses copines au dja en catch and baby tu seins",
|
|
||||||
"15:22:47": "Cest quoi litem ? Je sais pas On va aller prendre des stats cest lheure Tout ça toujours à cause memes hein Ouais là les yordles De toute manière on en aura branches comme dirait Malkai Exactement",
|
|
||||||
"15:23:09": "Oh oh caca Wade il est là ! Si tu me razes cest la win assuré. attends je re-farm un peu. Attends Voilà Why you good girls like always the bad guys ? Jadore cette ref hein. Et quoi Wick Est-ce que pourrais pas avoir meilleur exemple",
|
|
||||||
"15:24:13": "... Come black athletics ! Attention le retour du micro équipe Bien cest dans micro.",
|
|
||||||
"15:24:33": "Jen ai marre des yordles Ouais Ils sont là ils nous volent notre travail Cest qui cest Contre je suis ah on a juste Ah bah non Eh bien gentil Mais jamais de la vie tu me stat check en fait Lui par contre beaucoup plus... si",
|
|
||||||
"15:25:16": "En plus le pire cest quil y a quelques spectateurs mais juste quils ont même pas claqué leur lurk Ils sont là ils tout crotteux tout... Si vous avez un ref tapez 1 dans chat sil plaît Cette manière de forcer linteraction On dirait youtubeur qui est parle dun sujet tire plein milieu du fait je sais trop par rapport à ça précisément pouvez me préciser les commentaires",
|
|
||||||
"15:25:41": "Les mecs dans les commentaires sil vous plaît dites-moi si avez kiffé le doigt pétou au début de la vidéo Non cétait quoi ? Cétait une je crois que Tartin ou sais pas trop Ils regardaient émission et ils faisaient Ah ouais connais sur Montpellier est-ce pouvez me citer genre des bonnes adresses tout en commentaire habitent à mais cest même ça jétais là suis dit Tu vois mec",
|
|
||||||
"15:25:59": "6 personnes sur le stream vous êtes où les gens",
|
|
||||||
"15:26:42": "Cest une méthode de forceur pour avoir des commentaires ! Parce quil les lira jamais hein Je vous le dis tout suite moi-même je suis créateur contenu lire parfois ça me fait chier premier degré. Alors cest pas discuter avec gens Genre degré Moi lis vu que jen ai deux. Ouais après moi par jour en moyenne entre 10 et 15 si prends toutes plateformes. Larme sang coulant au monde joue. Entre étoiles.",
|
|
||||||
"15:27:00": "le jus de pipi pressé du zizi voilà",
|
|
||||||
"15:32:21": "directement posé vu que je bouge mes fesses allègrement se fait gagner deux toboggans en commentaire si vous tapez un dans les salles sil plaît taper fais et",
|
|
||||||
"15:32:44": "Pourquoi ça ? Nan mais... Tas entendu ce que jai dit Jai moi par jour jen ai deux Et les larmes de sang qui coulent Les gars commentaires sil vous plait Tapez un dans le chat Putain merde Oh forceur",
|
|
||||||
"15:41:14": "Enorme ESSEC MSI Oh nan gros caillou contre arbre Ouais cest un bon résumé du matchup Tu sais que le il est vraiment con Sil vous plait sil Moi je viens de me faire démonter Gros se Par win la game",
|
|
||||||
"15:41:25": "Enorme ESSEC MSI Oh nan gros caillou contre arbre Ouais cest un bon résumé du matchup Tu sais que le il est vraiment con Sil vous plait sil Moi je viens de me faire démonter Gros se Par win la game",
|
|
||||||
"15:48:35": "et là je parachute allez vas-y cest bon ouais mieux suis daccord vais retourner sur le jeu de golf jaimerais bien traîner les trous glace jai vraiment du mal avec faire solo ou alors il y a des viveurs en vrai ? non attendez un peu après peut-être sil chaud ah ma répondu hitscan euh",
|
|
||||||
"15:49:12": "Cest ce quil ma répondu",
|
|
||||||
"15:49:52": "Et pourquoi jai de la pub ? Elle est où Quel site me met Ok. Javais tout cassé frère. pas tant que là. Ah vas-y go à prendre glace Je suis trop fort sur toutes les maps mais je allé chier glace.",
|
|
||||||
"15:50:11": "Le piéçu we are working on fixing the lag comp with some really big improvement to network speed for client which is super exciting. Ouais bah texcites pas trop quand même. Reste calme Je le trouve un peu excité.",
|
|
||||||
"15:50:49": "je me bats pas avec les autres le mieux cest gauche ou droite là en même temps its ok il ouvre trou ça dur vrai trouve donc si passe par",
|
|
||||||
"15:51:10": "je manque trop dxp en fait de glace vraiment si tu me mets sur une dernier jai du mal fais bête par là la texture le que ce soit mon cerveau se dit ici à gauche vais essayer peut-être aller directement celui comme ça",
|
|
||||||
"15:51:49": "Ok jai une idée normalement cest un 30 Normalement Est-ce que ça a été net ? Quest-ce cette paralysie Ouais me paraît impossible ok Cest mais",
|
|
||||||
"15:52:11": "Ah jarrive ici Si je passais par lautre passing jétais sur une way beaucoup plus normale Jai rien à monter cest ça Cest Cependant suis moins en danger Là faut mettre un angle de Hum Je pense Ya larbre hein 30 Le truc que si passe Assez force pour ne pas taper la colline Jaurai naturellement trop Pour aller jusquau trou quoi bon",
|
|
||||||
"15:52:51": "Sable et neige ouais mais là jai pas tapé dans la on est daccord que ? Cest un 30 il prend les cadeaux sur Supergolf Là je tape Ah putain ah my bad",
|
|
||||||
"15:53:11": "Il fume des cigarettes ! Ah bah cest un 15 degrés alors. Mais pourquoi il sarrête net ? Je suis peut-être dans une paralysie du sommeil on sait pas. attends la balle ne roule pas en fait Cest même texture qui empêche de rouler ça normale",
|
|
||||||
"15:53:51": "Il doit être ici jimagine Vitesse du joueur Putain Incroyable Cest tellement bien pour le trailing Merci beaucoup Nat On retente En vrai là si je suis sur côté droit Je devrais aller à droite tabou dis",
|
|
||||||
"15:54:12": "Tu te fous de ma gueule quon peut mettre la vitesse du joueur à 200% dans les paramètres ? Cest vrai Attends mais... parce que tu tapes larbre Ah peut-être attends jai pas vu. mais par contre sil me dit vraiment set up pour aller et coup arrêter juste marcher là le call millénaire là. Règle",
|
|
||||||
"15:54:52": "Le jeu il est 40 fois mieux à vitesse de 100% Ils ont pas le même feeling ? Bah si les tirs sont mêmes là on train Ça change rien promis Tinquiète pour avoir full et faire plein lobbies Ah ouais Dout dout Mais ça cest la",
|
|
||||||
"15:55:11": "Cest glace cest diff ça Ok cétait bien Vas-y bah attends je retente le tir là-bas du coup Effectivement devait être larbre au final vous aviez raison Du si mets moins dangle genre 15 degrés",
|
|
||||||
"15:55:53": "Oui mais là la glace cest pas tankable en fait La nest Ah vrai 15 degrés cétait mal du coup vent un peu à gauche donc comme ça Trop fort Ok On le revoit avant J1 de lane Oh les gars Tu vas stream jusquà tard ? Euh ouais je vais Je",
|
|
||||||
"15:56:15": "Ok ouais javais mis... Je mettais combien de degrés ? 30 Cest trop bien pour Train cest une folie. incroyable mec ! Mais attends mais là libération la vitesse du jour. Merci Léger les 7 mois merci beaucoup. Pour éviter fermeture lentreprise Visual 45 et Taborlin 22 mois. Bah je pense que déstream énormément là. Et me supprime à balle.",
|
|
||||||
"15:56:56": "Je vais y arriver en fait Ouais les serveurs croque-up sont fermés On a mis la vidéo speedrun sur Youtube me fais neige diff putain Bah le jour J cest ce quil va se passer mettre dans je pense Mais jaimerais bien un peu plus Là on peut jouer bah là fini Cest terminish",
|
|
||||||
"15:57:18": "Jai juste peur En fait jai que Aujourdhui cest mon dernier jour de coaching à Joff Et vraiment il me tue Parce là vient dire 20h30 23h30 un petit peu Je sois mort Du haut Bah vous savez tout ce qui est ça en Ça fatigue énormément vais y arriver la mettant le dis je Si pensez quitter",
|
|
||||||
"15:57:59": "Bah de rien en fait Ok En cest important Kilo que genre Je mhabitue au dernier trou De chaque map glace Après là pas vraiment les maps intéressantes Parce on est sur la surface glacée Là jai jamais trop passé milieu aussi jaimerais bien apprendre Merde je me suis toujours chié dessus Et à gauche",
|
|
||||||
"15:58:21": "Attends je reset jai raté mon 100% Ya quelque part le WR de lAcro Cup On a mis une vidéo mais depuis il été battu Par euh... Je crois que cest soit Kishko 957 Jai eu la dernière récemment 974 pardon Cest un des deux ouais Jaurais jamais cru Était aussi fort au jeu par contre",
|
|
||||||
"15:59:03": "Et là... Attends je vais reset la map. Il est intéressant le checkpoint. Jen ai pris aucun là. Ah si jen un. Attends. il y en a un là aussi. Ok. En fait plus commence à travers jeu comprends quil faut prendre les checkpoints. Quitte faire des... Surtout sur maps glaces quoi. Non depuis start ça donne quoi checkpoint ? Je enlever map 1. Oh cest direct.",
|
|
||||||
"15:59:26": "Salut Cod ! à ceux qui arrivent. En bref faut pas passer middle. Bah en fait middle cest quoi deux tirs ? Je me rappelle plus. Ouais il semble. sais si la mine ça compte comme un tir. Merde jai tellement lhabitude de poser maintenant. Ah bah Donc tirs. Une tir ou euh... ok non Après ouais tu te prends fusil tombes dans leau instant. Vous savez ce quil regarder le checkpoint.",
|
|
||||||
"16:00:06": "Il y en a un tout droit à gauche. Ouais tu prends ce cube là into checkpoint. Ouais. Le checkpoint ça fait que si tombes dans leau il ny rien de grave enfin grave.",
|
|
||||||
"16:00:26": "Faut vraiment aller les chercher en fait De toute façon on est obligé de passer là bon il y a 90% chance que tu meurs Mais ce qui nest pas grave puisque je respawn Ok jai compris la map vrai Ouais par contre effectivement cest un danger public On avait vent comment ? face Jarrive sur cette surface Est-ce raté mon...",
|
|
||||||
"16:01:07": "On va vérifier Ouais je sais que peux utiliser mon truc pour geler Mais bon Cest quand même mieux sur les ennemis quoi Ça donne de la speed en plus Attends jai quel type vent là ? Ok un à droite Tu passes normalement le boost vitesse Non non tu Je passe",
|
|
||||||
"16:01:29": "Ah déjà ça cest grave Jai pas raté mon tir on est daccord mais Cest ou Non pris le checkpoint Bien que jy pense Là il se prend naturellement celui-là si tu peux tas bot fais clairement Ouais non dur En vrai avec vent de devant je passe Jaimerais bien choper un bon",
|
|
||||||
"16:02:09": "Jsuis même pas en train de vanner par contre jsuis sur la glace quoi Putain ! Ok ok bah alors problème réglé fait dans tous les cas Jen ai vraiment trop mis gars ? Je men suis trollé une petite là attendez Il troll ou",
|
|
||||||
"16:02:30": "après ça change rien cest juste quavant avec vent de tu peux faire en trois coups quoi là le il est compromis à jinvente roger jeu meilleur qui existe checkpoint je prends comme on daccord tellement malin passer par plutôt que sur la glace pour récupérer diff vente devant mettre deux",
|
|
||||||
"16:03:10": "Cest la cause de colline Limite je fous un 60 comme ça Ya même pas van En vrai On est daccord fait faut dodge les petites montées Parce que là",
|
|
||||||
"16:03:30": "Jétais où ? là Je vais mettre moins Ah mais non putain cest le... ouais vous avez compris En fait La colline me nique savez ce que... Merde oh heureusement je dois pas 5°C",
|
|
||||||
"16:04:09": "Déjà je suis obligé de mettre des vrais degrés on va dire My bad Ok Je pense mais limite 15 alors En vrai ça parait peut-être le plus malin Hmm Ça avait lair ok",
|
|
||||||
"16:04:28": "Là avec 5° je suis dead dans tous les cas Ouais déjà la balle Déjà cest pas possible Ya assez dangle et du coup prends montagne",
|
|
||||||
"16:05:07": "Je sais pas si jai 15 ou 60 en fait Tu veux retenter un tir partant du même point tu peux mettre Ouais jessaie Sauf tas pris checkpoint quoi",
|
|
||||||
"16:05:27": "Non bah moi je vais être team 60 pense hein En vrai les gars Pour cest masterclass Bah par contre la force quil faut pas mettre Ouais plus précis Objectif pour Zilean ? gagner Quest-ce que tu prends en fait À QUI TU PARLES Jai le meilleur joueur du monde avec choix",
|
|
||||||
"16:06:05": "Ah cétait ça Je vais rentrer de plus en dans la team 60 degrés toute façon",
|
|
||||||
"16:06:24": "60 les rebonds sont petits ouais mais attends je suis pas assez en haut du coup men rends compte ou mette moins faut mettre jusquau point blanc dirais jen ai trop mis encore",
|
|
||||||
"16:07:05": "Là en vrai on est daccord que là cest du 5 degrés là. tous daccord. Bah oui. En fait ça dépend vraiment de linclinaison. Si je vois que... Comment dire ? ce qui va se passer faire tremplin. jai pas le choix. Ok faut reset masse Quoique il où premier checkpoint Oh putain. Merde.",
|
|
||||||
"16:07:23": "Putain ça rebondit par contre Quoi ? Cest possible Ah là je suis trop à plat Là tu vois attends mais",
|
|
||||||
"16:08:04": "Attends en vrai ce que je vais faire cest me coller au bord Et A laide perte Je et vérifier Que ne peux pas dépasser le trou Ok jétais tout vent pour Bord de colline Sans",
|
|
||||||
"16:08:25": "Tenvoies où ça ? Ok Cest impossible de faire la montagne en un coup bon à savoir tomber deux coups Jai littéralement le meilleur vent possible Donc cest officiel Ici tenvoies une bastos ou pas Ou tu dépasses En vérification si on peut dépasser depuis haut colline",
|
|
||||||
"16:09:06": "et je nai pas dépassé le trou donc si on reprend la map mais moins dangle pourquoi jen mettrais cest est très content de ce qui sest passé là heureux jai envie dessayer un autre angle comme jallais mettre du directement depuis ou quoi limportant placer forcément correctement ok nous avons linverse vent 1",
|
|
||||||
"16:09:27": "Donc là je vais avoir du mal à atteindre même le haut de la colline peut-être Bon dose pas non cest bon Pardon excusez-moi Ah merde jai mis 101 Cest très grave dans tous les cas 100% et bonne chance Il peut rien marriver quoi En fait ne ce quil faut retenir Ok",
|
|
||||||
"16:10:06": "On va tester on un 30% Je pense 100% ça part dans la lune non ? Ok Javais mis 30 degrés ou 45 Non javais Donc",
|
|
||||||
"16:10:27": "Cest un map assez simple ouais. Là cest 5 degrés. Ok. Ok trou très simple. Ah celui-là il est intéressant celui-là. Celui-là va être reset en boucle. full data. Alors quest-ce quil y a ?",
|
|
||||||
"16:11:07": "Ok ok tinquiète. Jai coaching de 20h à 23h sur Adjof. Et tac je suis en train créer une folie là la map là. Tu sais que viens faire... Enfin ça peut se... Il ma manqué quoi les gars ? Après faut se dire... Ah putain mais imagine tu...",
|
|
||||||
"16:11:28": "Ça serait quoi ? 30 degrés mais avec... Ah jai vent pour aussi. Jai pour. Si je mets jusquau point blanc là ça fait 2v2 golf. Attends traîne mes maps de glace tac. Il faut y passer là. Cest bien que cest les dernières à traîner. Merde a reset. Putain suis tombé.",
|
|
||||||
"16:44:30": "Oh la mais pourquoi javais 15% ? Ah jai 1 Non cest pas une spawn tinquiète moi jsuis frelant jcomprends les tirs vraiment un gros problème avec Jai niqué sa game ouais deux games Les nont plus de Ils ont des spawns au start genre Ok bah go stack items",
|
|
||||||
"16:45:09": "Aller 2 nont plus de game là 3 items Tas vu ce que jai fait avant ? Ouais ouais Ils sont loin Non mais cest pas ça le cut sans boot Ah merde jsuis un trou balle Oh non dur bah",
|
|
||||||
"16:45:51": "Ils ont beau être de linverse ils vont faire des trucs meufs Heureusement quils ne le met pas parce que là me faisaient points fou Ah cest ma gueule En fait font très attention à beaucoup peu importe ce quil se passe Ouais maintenant juste moi qui est int en vrai Après ça va je les fasse respawn au start quon quand même cétait broken",
|
|
||||||
"16:46:13": "Jai mis trop fort Ah là cest my bad je perds quoi ? Je 10 points là-dessus Ouais même 20 waouh dur mais non dans tous les cas javais un coup de pénalité oui tavais pris merci Grisco Oui rien En fait jai drive fusée au début ce qui pense est excellent Euh... Bah sauf quen euh... Tas tiré avec le club en ouais certes vraiment bon 15% Aaaah ok legal",
|
|
||||||
"16:46:54": "Mais its fine Il est plus loin que moi et je suis sous café en vrai cest pas mal Moi peux reprendre évidemment full item vraiment bien Ah ouais nickel Ouais il chaud la mis direct là ? Non non dommage devant le trou putain Je souffle Z Vas-y nique lui Nique sil te plait Jai niqué les deux pour linstant",
|
|
||||||
"16:47:15": "Prends litem devant Ouais Viens en voiture viens Ça me la cancel ? Elle est où te Ah vas-y FF Mais alors Ice Non trop tard cest possible Dommage Je les shoot je il a voulu mettre une mine pense que suis loin pour prendre ah non prends quand même",
|
|
||||||
"16:47:57": "Un... Ouais quatre aussi. Quand jétais vraiment close de faire le spray je manquais un tout petit peu puissance. Euh ouais crois que cest... Ok bah ça dépend ce tas fait bon. attends jai quoi cest vrai ? Non rien pour droite gauche. Je suis quasiment sûr. Jai fusil tente pas non mais quune balle. pense passe peut-être.",
|
|
||||||
"16:48:17": "Je lai mis je crois la mets Ouais tu BJ très bien joué Jai encore des items moi jai trucs mais que cest pas ouf De toute façon Kailiris elle a 3 coups donc va faire en 4 Donc ça trop de points Moi fait",
|
|
||||||
"16:48:57": "Ok il a utilisé son shield pour moi Cest fine Bon va prendre Il est tombé là ? Oh jamais ! Une glace Ah bah dommage ma balle passée à 1cm de... non je lai mise me disais bien quelle était dedans Tes juste avant de trop fréo tes Et vraiment loin toi en plus",
|
|
||||||
"16:49:19": "Une balle jprends pas le risque Ah bah jai des bots cest dommage Faut les tenter Je suis daccord frérot Its fine ouais Oh il vient de marriver une folie là men trollé petite Moi je bien dans ma game quand même back hein vais snowball tous items et tout Mais sais où est quelle mais elle lautre côté On passe 2 3",
|
|
||||||
"16:50:01": "Jai fait 20 points de plus que tout le monde du coup Ok je suis full item Allez map 2 quon joue Cest pas une pour mes items là Je crois jai bagnole Jsuis sûr attends Tas cest ça glace et quaprès on Ah Bah laisse moi glacer dabord pense Vas-y bah vas-y va être en limite hein dommage",
|
|
||||||
"16:50:22": "Jai des items de fou là Magnifique Non mais putain jai mis le coup fusil je me suis tombé dans leau en fait Ouais cest ok on beaucoup points moi un gros tour Ah ouais 140 quoi qui ta donné autant ? cétait trou moins coups genre tu vois depuis la plateforme où tas tiré première balle lai mise direct incroyable Cest abusé comme ça envoie masse Mais...",
|
|
||||||
"16:51:04": "Ah dommage Vas-y garde la bagnole Joue toi fais ta first place Mais ma balle est en dessous fait La dans ton ventre ça pète les couilles Jai tout pour quil monte pas Je peux monter le pylône avec ? Lambiance va être extrêmement bizarre tinstalle je joue normal cassée vous dis",
|
|
||||||
"16:51:25": "Direction garage Ok ok Np Jsuis toujours sous triple item Tas quoi ? Attends jtire dabord Oh vas-y ta mère il se TP lag Faut que Guzz a le ça sert un peu plus de toucher Il tiré dans ma balle Enfin là nombre coups incalculable Ouais Cest là-bas Non elle est en train venir",
|
|
||||||
"16:52:08": "Allez premier 100 Premier oui cest inquiétant Ah bah moi je tavoue jai pris 4 items dans le round là jétais pas très content Jessaie de te back up mais même la glace na pas... Le sable ici il fait trop dégâts faut mettre 5 degrés limite Ouais toute façon men foutais javais Cest du tout chose que gazon en",
|
|
||||||
"16:52:29": "Il a plus la merde ma ice non vas-y lice me fait tomber mais ch*** Après moi je suis loin et ils ont double item pour est en train de mentrôler une petite fois Juste faut que fasse first Toi tu fais cest fou hein Oula On bête Bon par contre le nombre coups inhumain Je à 40 là",
|
|
||||||
"16:53:11": "Moi jai de la ice si je peux les baiser sans te Casse toi casse Je leur pète couilles en vrai Bah oui du coup vu que cest à taper sur ce bouclier Ok my bad Jy avais même pas pensé il reste va nous mille a loupé bazook pistolet Sil y un mec qui essaye passer est mort suis sorte tomber dans leau Oh ma ramené balle",
|
|
||||||
"16:53:33": "Ouais non le sable il fait mal. Là tu las train. je ne sais plus ce que faisais mais ouais. Je crois nai pas passé beaucoup de temps. Non la chatte jai mis... Il a eu temps me toucher là. Peut-être quon est chez les fous pas. Et en là ils ont free bagnole parce na rien pour counter. Cest minediv quand même dans gueule Ah bien joué. Jusquà quil ait sa balle.",
|
|
||||||
"16:54:13": "Allez hop là frérot tes fini Je lai mis dans le grid hein Guizia Ok je peux plus tomber mais cest bon un vide partout mec Oh non lUlux il faut quil se déconnecte Ya qui sest connecté Ouais lui ai dit de séclater",
|
|
||||||
"16:54:34": "Jai fait un truc trop Ok jaurais dû faire 30 points Ah bien joué ça Tas eu le checkpoint ? Ouais cest bon il arrive sa balle elle est dans la merde donc si tu peux jouer Faut que joues hein Tu 3 Elle a pris full item nonobstant fine tous les cas on 2 F",
|
|
||||||
"16:55:14": "Ouais je crois Vas-y Jsuis pas sûr attends Faut tester un peu Je suis sous 3 guns BG Du coup la tape laisse prendre litem Ah cest bien 30% jai ditem joue ton jeu",
|
|
||||||
"16:55:35": "Bien joué arrête de jouer sa part On va nique là on pêche à te finir si tas 10 thèmes Ouais jai pas mais... Ok contrôle une petite dommage stack thème devant toi Jump dessus tu veux Non perd Regim Putain Tas 3 kits ?",
|
|
||||||
"16:56:16": "Ouais cest fine Oh ils sont allés dans le vide Ah putain jai pris bazooka sur la plateforme Non ça va trop loin ok bah bonne data Ça double fusil by 2 merde Attends mais comment tes pas... Jai petite île oui daccord Bon tant pis de toute façon on avait perdu game",
|
|
||||||
"16:56:34": "Boîte Survivor mais... On est qui ? Je regarde solo café ça passe Non pas. Ouais bah... Solo pas du premier au deuxième tour. Ah bah putain elle a eu le temps. Ça fait chier ça.",
|
|
||||||
"16:57:18": "Ok bon le cut... Vas-y les mecs En fait je pense que Cest exemples parce tavais tes potes quoi Ouais mais si tu peux aller sur lîle cest dangereux Parce prends nimporte quelle balle et finis dans leau Tu vois ce veux dire ? Alors par exemple tas truc de flamme golf ou genre... Ah non double pompe mas dit ça passait pas Non passe Bah sais alors Match Mais là rien faire en Ou alors...",
|
|
||||||
"16:57:39": "Si si tas shield en vrai Shield bot Bah ouais fait le cut il faut passer sous Là franchement nimporte quelle balle je meurs Ouais Et cest haut plus jai perdu mes items Donc euh Mais bon à savoir Je pense quon pas sauf Ou alors passe premier Qui",
|
|
||||||
"16:58:20": "Bon ça va en vrai Là faut se dire que cest des mecs Zilean Caliph et du coup ils ont très peu de jeux à train Par rapport nous on était quand même vraiment bien Donc Ils deux fois moins A donc On est large fait a une bonne presse je crois notre temps jeu sont tous 20-30h Pendant 10 Et gagner quoi fine",
|
|
||||||
"16:58:44": "Par exemple timagines tas bot et un tir. Tu le laisses passer tu tires dessus après bots. Le problème cest que sur la petite île si café passes pas. Donc ça veut dire peux rester aussi longtemps île. Ouais ça. En vrai y a quand même beaucoup de problèmes. Moi je dois aller. Vas-y vas-y bisous. traîne bien frérot tire-moi update du coup ce fais. Kéré. Vas-y. bisous team. Ciao.",
|
|
||||||
"16:59:24": "Le problème cest que vous avez moins de trucs à traîner En vrai jétais curieux la map La désert justement dernière Parce je sais pas si vu en passant par le cut Est-ce vais être capable retrouver Celle-ci hein ouais Quest-ce branle",
|
|
||||||
"16:59:43": "Non mais on a trop de jeux à traîner ne te rend pas compte Romani. Le temps quon perd sur plein trucs nous. Bah toute façon ça peut équilibrer chaque fois prend des joueurs qualifs ils ont deux nos heures le golf donc",
|
|
||||||
"17:00:26": "20 sur Age of Empires et cest le jeu où on doit en mettre plus Et il y a aussi un deuxième truc à prendre compte quà lasylane de golf est pas très important donc là nos heures quoi Bah alors que vous pour les qualifs Ouais tu vois je shield botte Ah mais javais contre fait chier Bon jai la chatte Ya moyen mine... attends jsuis",
|
|
||||||
"17:00:47": "Jfais de la merde attends. Faut que jenlève tout sauf le nom. Cest quoi son nom ? Vas-y ça va. Bah ouais chaque fois quon demande les heures KennyChill il est à 30 euh... Nous on 15 quoi. En fait a 3 mètres partout. Age of Empires cest tellement herdif. inhumain en fait. On doit avoir..."
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"20:52:11": "Adieu les gars Il y en a un qui mort Un water push",
|
||||||
|
"20:52:41": "Il est seul là du coup ça veut dire quil là. Non euh... Sortie temple. Asas. Je suis white. plié. Ah il sorti ! re-rentré. One cam. hauteur. Putain. sais même pas doù ma tué.",
|
||||||
|
"20:53:11": "lautre jen ai eu un au moins alex oh il régale fp7 la star du match gg tout papa me hol là ok jai compris et puis cest ben tu rigoles merci beaucoup",
|
||||||
|
"20:53:41": "Merci beaucoup CursorStorm frérot Oh ! Snipe Jai pas de snipe là Bah vas-y je vais prendre le scout alors Il y a un cave crois ? est close Un Nice Main B",
|
||||||
|
"20:54:11": "je crois que vous allez venir avec moi le vois pas il est repassé côté bp puisque non mais cest dinguerie frère jen suis quun seul une belle tête ce coût fp7",
|
||||||
|
"20:54:41": "Tu rigoles tu viens doù en fait ? Merci merci beaucoup Extraterrestre Oh la Comme tomate Ah vous rigolez les gars 30 secondes je pensais aller bien Ouais mais jattendais dêtre un petit peu plus loin",
|
||||||
|
"20:55:11": "Mec tu me refais hein En vrai le kill scout il valait 2 kills Tes un bon tes grave merci Un ptit frontalier suisse mérites cest minimum Mamacita Allez une tombe datomate pour des c**",
|
||||||
|
"20:56:11": "Oh putain je suis trop tard. Un fill. Ouais. Jai même pas touché le mec. Ah ! Bah écoute ça arrive hein Witz. Cest arrivé à nimporte qui Je côté Genève. Mais non connais grave Yvonne Lesbain. Il y a mon cousin habite là-bas. Et jallais beaucoup Entre Lesbain et...",
|
||||||
|
"20:56:41": "dans les bords javais il fut un temps je fréquentais quelquun là bas cest quoi mais oui jen ai déjà tu griffes tes jamais puni",
|
||||||
|
"20:57:41": "je connais un asiat si cest ton cousin me tirer une balle juste la coïncidence il travaille dans linformatique ou pas cochon ny a de chien pour lest mais nous on mange beaucoup cochons tu le sais merci m bon ralou fleur du port quai type en là",
|
||||||
|
"20:58:11": "Nous aussi les polaks Ah nan tes polak ! On parle pas du meme Tiens ça aurait été trop fort la coïncidence mec Deux mid Nice Jarrive au ring Il y en a un low Behind One"
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"id": "sr_1777392840104_806",
|
||||||
|
"enabled": true,
|
||||||
|
"contains_text": "clip",
|
||||||
|
"action": "clip",
|
||||||
|
"as_account": "exoticnaturees",
|
||||||
|
"message_text": "",
|
||||||
|
"clip_has_delay": false,
|
||||||
|
"clip_send_message": true,
|
||||||
|
"clip_message_template": "Clip: {url}",
|
||||||
|
"cooldown_seconds": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"ts": "2026-04-28T20:32:55",
|
||||||
|
"type": "clip_created",
|
||||||
|
"channel": "infinity_azn",
|
||||||
|
"subtitle_ts": "20:32:51",
|
||||||
|
"subtitle": "Jai eu le temps de débug Clip",
|
||||||
|
"rule_id": "sr_1777392840104_806",
|
||||||
|
"as_account": "exoticnaturees",
|
||||||
|
"clip_url": "https://clips.twitch.tv/DirtyMagnificentCormorantTinyFace-ismmH56vpbTRzkMy",
|
||||||
|
"clip_edit_url": "https://www.twitch.tv/infinity_azn/clip/DirtyMagnificentCormorantTinyFace-ismmH56vpbTRzkMy",
|
||||||
|
"clip_id": "DirtyMagnificentCormorantTinyFace-ismmH56vpbTRzkMy",
|
||||||
|
"chat_message": "Clip: https://clips.twitch.tv/DirtyMagnificentCormorantTinyFace-ismmH56vpbTRzkMy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-04-28T20:35:45",
|
||||||
|
"type": "clip_created",
|
||||||
|
"channel": "infinity_azn",
|
||||||
|
"subtitle_ts": "20:35:42",
|
||||||
|
"subtitle": "Beaucoup beaucoup de décalage... Ah merde ! Dailleurs au fur et à mesure normalement jai dit à... Enfin dit... Jai fait en sorte quil y ait les liens des clips mais je sais pas si ça saffiche là limpression. Bon allez.",
|
||||||
|
"rule_id": "sr_1777392840104_806",
|
||||||
|
"as_account": "exoticnaturees",
|
||||||
|
"clip_url": "https://clips.twitch.tv/UninterestedResoluteCormorantDxAbomb-6F71GwISaLGwnunt",
|
||||||
|
"clip_edit_url": "https://www.twitch.tv/infinity_azn/clip/UninterestedResoluteCormorantDxAbomb-6F71GwISaLGwnunt",
|
||||||
|
"clip_id": "UninterestedResoluteCormorantDxAbomb-6F71GwISaLGwnunt",
|
||||||
|
"chat_message": "Clip: https://clips.twitch.tv/UninterestedResoluteCormorantDxAbomb-6F71GwISaLGwnunt"
|
||||||
|
}
|
||||||
|
]
|
||||||
+171
-27
@@ -31,6 +31,9 @@
|
|||||||
<a class="nav-link navbar-sitemap-link" data-main-tab="interaction-tab" href="#" onclick="activateMainTab('interaction-tab'); return false;">
|
<a class="nav-link navbar-sitemap-link" data-main-tab="interaction-tab" href="#" onclick="activateMainTab('interaction-tab'); return false;">
|
||||||
<i class="fas fa-at me-1"></i>Interaction chat
|
<i class="fas fa-at me-1"></i>Interaction chat
|
||||||
</a>
|
</a>
|
||||||
|
<a class="nav-link navbar-sitemap-link" data-main-tab="clips-tab" href="#" onclick="activateMainTab('clips-tab'); return false;">
|
||||||
|
<i class="fas fa-film me-1"></i>Clips
|
||||||
|
</a>
|
||||||
<a class="nav-link navbar-sitemap-link" data-main-tab="settings-tab" href="#" onclick="activateMainTab('settings-tab'); return false;">
|
<a class="nav-link navbar-sitemap-link" data-main-tab="settings-tab" href="#" onclick="activateMainTab('settings-tab'); return false;">
|
||||||
<i class="fas fa-sliders-h me-1"></i>Paramètres
|
<i class="fas fa-sliders-h me-1"></i>Paramètres
|
||||||
</a>
|
</a>
|
||||||
@@ -75,6 +78,11 @@
|
|||||||
<i class="fas fa-at me-2"></i>Interaction chat
|
<i class="fas fa-at me-2"></i>Interaction chat
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="clips-tab" data-bs-toggle="tab" data-bs-target="#clips" type="button">
|
||||||
|
<i class="fas fa-film me-2"></i>Clips
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link" id="settings-tab" data-bs-toggle="tab" data-bs-target="#settings" type="button">
|
<button class="nav-link" id="settings-tab" data-bs-toggle="tab" data-bs-target="#settings" type="button">
|
||||||
<i class="fas fa-sliders-h me-2"></i>Paramètres
|
<i class="fas fa-sliders-h me-2"></i>Paramètres
|
||||||
@@ -196,21 +204,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6 col-xl-4">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<div class="fw-semibold">Envoi Messages Chat</div>
|
|
||||||
<div class="small text-muted">Interrupteur global: autorise/bloque tout envoi sur Twitch.</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input class="form-check-input" type="checkbox" id="chatMessageToggle" onchange="toggleChatMessage()">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2">
|
|
||||||
<small class="text-muted" id="chatMessageStatus">Désactivé</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-6 col-xl-6">
|
<div class="col-md-6 col-xl-6">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<div>
|
<div>
|
||||||
@@ -308,7 +301,12 @@
|
|||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="card bg-secondary">
|
<div class="card bg-secondary">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h5><i class="fas fa-microphone me-2"></i>Dernier Texte Détecté</h5>
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-microphone me-2"></i>Dernier Texte Détecté</h5>
|
||||||
|
<select class="form-select form-select-sm" id="subtitles-flux-select" style="max-width: 220px;" onchange="onSubtitlesFluxChange()">
|
||||||
|
<option value="-1">Flux…</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="alert alert-warning" id="last-subtitle">
|
<div class="alert alert-warning" id="last-subtitle">
|
||||||
@@ -337,8 +335,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="card bg-secondary">
|
<div class="card bg-secondary">
|
||||||
<div class="card-header">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<h5><i class="fas fa-list me-2"></i>Historique des Sous-titres</h5>
|
<h5 class="mb-0"><i class="fas fa-list me-2"></i>Historique des Sous-titres</h5>
|
||||||
|
<button class="btn btn-outline-light btn-sm" onclick="refreshSubtitlesAll()">
|
||||||
|
<i class="fas fa-sync me-1"></i>Rafraîchir
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div id="subtitles-history" class="subtitle-history">
|
<div id="subtitles-history" class="subtitle-history">
|
||||||
@@ -348,6 +349,59 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row mt-3 g-3">
|
||||||
|
<div class="col-md-7">
|
||||||
|
<div class="card bg-secondary">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-bolt me-2"></i>Règles (sous-titres → actions)</h5>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-outline-light btn-sm" onclick="refreshSubtitleRules()">
|
||||||
|
<i class="fas fa-sync me-1"></i>Recharger
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-success btn-sm" onclick="saveSubtitleRules()">
|
||||||
|
<i class="fas fa-save me-1"></i>Sauvegarder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="subtitle-rules-enabled">
|
||||||
|
<label class="form-check-label" for="subtitle-rules-enabled">Actif</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr class="border-secondary my-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h6 class="mb-0"><i class="fas fa-list me-2"></i>Règles</h6>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="addSubtitleRule()">
|
||||||
|
<i class="fas fa-plus me-1"></i>Ajouter règle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="subtitle-rules" class="d-flex flex-column gap-2"></div>
|
||||||
|
<div class="form-text text-muted mt-2">
|
||||||
|
Déclenche une action quand un sous-titre contient un mot/texte.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="card bg-secondary">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-clipboard-list me-2"></i>Logs règles</h5>
|
||||||
|
<button class="btn btn-outline-light btn-sm" onclick="refreshSubtitleRulesLog()">
|
||||||
|
<i class="fas fa-sync me-1"></i>Rafraîchir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="subtitle-rules-log" class="subtitle-history">
|
||||||
|
<div class="text-muted text-center">Aucun log</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Interaction Chat Tab -->
|
<!-- Interaction Chat Tab -->
|
||||||
@@ -377,8 +431,8 @@
|
|||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label small text-muted" for="interaction-mode">Mode</label>
|
<label class="form-label small text-muted" for="interaction-mode">Mode</label>
|
||||||
<select class="form-select form-select-sm" id="interaction-mode">
|
<select class="form-select form-select-sm" id="interaction-mode">
|
||||||
<option value="predefined">Réponses préenregistrées (défaut)</option>
|
<option value="predefined">Réponses préenregistrées</option>
|
||||||
<option value="tgpt">TGPT</option>
|
<option value="tgpt">TGPT (IA)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
@@ -387,13 +441,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-3 mt-1">
|
<div class="row g-3 mt-1" id="interaction-tgpt-settings">
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input class="form-check-input" type="checkbox" id="interaction-tgpt-enabled">
|
|
||||||
<label class="form-check-label" for="interaction-tgpt-enabled">TGPT activé</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-5">
|
<div class="col-md-5">
|
||||||
<label class="form-label small text-muted" for="interaction-tgpt-preprompt">Préprompt TGPT (global)</label>
|
<label class="form-label small text-muted" for="interaction-tgpt-preprompt">Préprompt TGPT (global)</label>
|
||||||
<textarea class="form-control" id="interaction-tgpt-preprompt" rows="2" placeholder="Ex: Réponds brièvement en français."></textarea>
|
<textarea class="form-control" id="interaction-tgpt-preprompt" rows="2" placeholder="Ex: Réponds brièvement en français."></textarea>
|
||||||
@@ -457,6 +505,86 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Clips Tab -->
|
||||||
|
<div class="tab-pane fade" id="clips" role="tabpanel">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="card bg-secondary">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-film me-2"></i>Créer un clip</h5>
|
||||||
|
<button class="btn btn-outline-light btn-sm" onclick="prefillClipChannelFromActiveFlux()" title="Utiliser le flux actif">
|
||||||
|
<i class="fas fa-wand-magic-sparkles me-1"></i>Auto
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small text-muted" for="clips-user-select">Utilisateur (token)</label>
|
||||||
|
<select class="form-select form-select-sm user-selector" id="clips-user-select">
|
||||||
|
<option value="-1">Sélectionner un utilisateur...</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text text-muted">Le token doit avoir le scope <code>clips:edit</code>.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small text-muted" for="clips-channel-login">Chaîne Twitch (login)</label>
|
||||||
|
<input class="form-control form-control-sm" id="clips-channel-login" type="text" placeholder="ex: exoticnaturees">
|
||||||
|
<div class="form-text text-muted">Entrez le login sans URL. Le @ est accepté.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-check form-switch mt-4">
|
||||||
|
<input class="form-check-input" type="checkbox" id="clips-has-delay">
|
||||||
|
<label class="form-check-label" for="clips-has-delay">has_delay</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text text-muted">À activer si le stream a un délai (anti-streamsniping).</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 d-flex gap-2">
|
||||||
|
<button class="btn btn-success" onclick="createClip()">
|
||||||
|
<i class="fas fa-scissors me-2"></i>Créer le clip
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-light" onclick="clearClipResult()">
|
||||||
|
<i class="fas fa-eraser me-2"></i>Effacer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="border-secondary my-3">
|
||||||
|
|
||||||
|
<div id="clips-result" class="d-none">
|
||||||
|
<div class="alert alert-info mb-2">
|
||||||
|
<div class="fw-semibold mb-1">Résultat</div>
|
||||||
|
<div class="small">
|
||||||
|
<div>Clip: <a id="clips-public-url" href="#" target="_blank" rel="noopener noreferrer">—</a></div>
|
||||||
|
<div>Edit: <a id="clips-edit-url" href="#" target="_blank" rel="noopener noreferrer">—</a></div>
|
||||||
|
<div class="text-muted mt-1">ID: <span id="clips-id">—</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="card bg-secondary">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-gears me-2"></i>Automatisation (futur)</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="small text-muted">
|
||||||
|
Une base est prête côté API pour brancher une automatisation (ex: clip sur mot-clé, intervalle, événement).
|
||||||
|
Pour l’instant, aucune boucle n’est lancée automatiquement.
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<button class="btn btn-outline-light btn-sm" onclick="checkClipAutomationStatus()">
|
||||||
|
<i class="fas fa-circle-info me-1"></i>Statut
|
||||||
|
</button>
|
||||||
|
<div class="mt-2 small text-muted" id="clips-automation-status">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Settings Tab -->
|
<!-- Settings Tab -->
|
||||||
<div class="tab-pane fade" id="settings" role="tabpanel">
|
<div class="tab-pane fade" id="settings" role="tabpanel">
|
||||||
<div class="card bg-secondary">
|
<div class="card bg-secondary">
|
||||||
@@ -542,6 +670,22 @@
|
|||||||
Enregistrer l'audio du stream
|
Enregistrer l'audio du stream
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-check mt-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="send-messages" checked>
|
||||||
|
<label class="form-check-label" for="send-messages">
|
||||||
|
Autoriser l’envoi de messages sur ce stream
|
||||||
|
</label>
|
||||||
|
<div class="form-text text-muted">Contrôle par flux.</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-check mt-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="enable-ia" checked>
|
||||||
|
<label class="form-check-label" for="enable-ia">
|
||||||
|
Activer génération TGPT (IA) à partir des sous-titres
|
||||||
|
</label>
|
||||||
|
<div class="form-text text-muted">
|
||||||
|
Si désactivé: pas de génération TGPT et pas d’envoi automatique des messages générés.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuler</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuler</button>
|
||||||
|
|||||||
+54
-11
@@ -16,6 +16,8 @@ from fonction.first_class import (
|
|||||||
|
|
||||||
from twitch_bot import chat_state
|
from twitch_bot import chat_state
|
||||||
from twitch_bot.interaction_chat import InteractionChatProcessor
|
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:
|
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']])
|
'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
|
flux_id = len(self.flux_list) + 1
|
||||||
|
|
||||||
# Créer l'objet flux pour l'API (sans les instances de bots)
|
# Créer l'objet flux pour l'API (sans les instances de bots)
|
||||||
@@ -93,6 +95,8 @@ class BotController:
|
|||||||
'name': channel_name,
|
'name': channel_name,
|
||||||
'twitchname': channel_name,
|
'twitchname': channel_name,
|
||||||
'record_audio': record_audio,
|
'record_audio': record_audio,
|
||||||
|
'send_messages': bool(send_messages),
|
||||||
|
'enable_ia': bool(enable_ia),
|
||||||
'active': True,
|
'active': True,
|
||||||
'created_at': datetime.now().isoformat(),
|
'created_at': datetime.now().isoformat(),
|
||||||
'status': 'starting'
|
'status': 'starting'
|
||||||
@@ -137,13 +141,42 @@ class BotController:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
msg_bot_for_interaction = messageTwitch("config/user.json", channel_name)
|
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(
|
interaction = InteractionChatProcessor(
|
||||||
channel_name=channel_name,
|
channel_name=channel_name,
|
||||||
get_registered_accounts=get_registered_accounts,
|
get_registered_accounts=get_registered_accounts,
|
||||||
get_account_policies=get_account_policies,
|
get_account_policies=get_account_policies,
|
||||||
send_message_as=lambda pseudo, text: msg_bot_for_interaction.send_message_user(
|
send_message_as=lambda pseudo, text: (
|
||||||
_resolve_user_index(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] = {
|
self.bots[flux_id] = {
|
||||||
@@ -153,6 +186,7 @@ class BotController:
|
|||||||
'ia_bot': None,
|
'ia_bot': None,
|
||||||
'message_bot': None,
|
'message_bot': None,
|
||||||
'interaction': interaction,
|
'interaction': interaction,
|
||||||
|
'subtitle_rules': subtitle_rules,
|
||||||
}
|
}
|
||||||
chat_bot.start_background()
|
chat_bot.start_background()
|
||||||
|
|
||||||
@@ -165,19 +199,28 @@ class BotController:
|
|||||||
threading.Thread(target=record_bot.main, daemon=True).start()
|
threading.Thread(target=record_bot.main, daemon=True).start()
|
||||||
|
|
||||||
# Démarrer le bot de sous-titres
|
# 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
|
self.bots[flux_id]['subtitle_bot'] = subtitle_bot
|
||||||
subtitle_bot.start_main_loop()
|
subtitle_bot.start_main_loop()
|
||||||
|
|
||||||
# Démarrer le générateur IA
|
# Démarrer le générateur IA
|
||||||
ia_bot = IA_generator("config/config.json")
|
if bool(flux_data.get("enable_ia", True)):
|
||||||
self.bots[flux_id]['ia_bot'] = ia_bot
|
ia_bot = IA_generator("config/config.json")
|
||||||
ia_bot.start_main_loop()
|
self.bots[flux_id]['ia_bot'] = ia_bot
|
||||||
|
ia_bot.start_main_loop()
|
||||||
|
|
||||||
# Démarrer le contrôleur de messages
|
# Démarrer le contrôleur de messages (uniquement si autorisé sur ce flux)
|
||||||
message_bot = messageTwitch("config/user.json", channel_name)
|
if bool(flux_data.get("send_messages", True)) and bool(flux_data.get("enable_ia", True)):
|
||||||
self.bots[flux_id]['message_bot'] = message_bot
|
message_bot = messageTwitch("config/user.json", channel_name)
|
||||||
message_bot.start_loop_respond()
|
self.bots[flux_id]['message_bot'] = message_bot
|
||||||
|
message_bot.start_loop_respond()
|
||||||
|
|
||||||
# Mettre à jour le statut
|
# Mettre à jour le statut
|
||||||
flux_data['status'] = 'active'
|
flux_data['status'] = 'active'
|
||||||
|
|||||||
@@ -63,8 +63,10 @@ class InteractionRule:
|
|||||||
from_username: Optional[str] = None
|
from_username: Optional[str] = None
|
||||||
mention_account: Optional[str] = None
|
mention_account: Optional[str] = None
|
||||||
contains_text: Optional[str] = None
|
contains_text: Optional[str] = None
|
||||||
|
action: str = "response" # response | tgpt | clip
|
||||||
response_text: str = ""
|
response_text: str = ""
|
||||||
tgpt_preprompt: Optional[str] = None
|
tgpt_preprompt: Optional[str] = None
|
||||||
|
clip_has_delay: bool = False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_dict(d: Dict[str, Any]) -> "InteractionRule":
|
def from_dict(d: Dict[str, Any]) -> "InteractionRule":
|
||||||
@@ -74,8 +76,10 @@ class InteractionRule:
|
|||||||
from_username=d.get("from_username") or None,
|
from_username=d.get("from_username") or None,
|
||||||
mention_account=d.get("mention_account") or None,
|
mention_account=d.get("mention_account") or None,
|
||||||
contains_text=d.get("contains_text") 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 ""),
|
response_text=str(d.get("response_text") or ""),
|
||||||
tgpt_preprompt=d.get("tgpt_preprompt") or None,
|
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]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
@@ -85,8 +89,10 @@ class InteractionRule:
|
|||||||
"from_username": self.from_username,
|
"from_username": self.from_username,
|
||||||
"mention_account": self.mention_account,
|
"mention_account": self.mention_account,
|
||||||
"contains_text": self.contains_text,
|
"contains_text": self.contains_text,
|
||||||
|
"action": self.action,
|
||||||
"response_text": self.response_text,
|
"response_text": self.response_text,
|
||||||
"tgpt_preprompt": self.tgpt_preprompt,
|
"tgpt_preprompt": self.tgpt_preprompt,
|
||||||
|
"clip_has_delay": self.clip_has_delay,
|
||||||
}
|
}
|
||||||
|
|
||||||
def matches(self, *, username: str, message: str, mentioned_accounts: List[str]) -> bool:
|
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):
|
def __init__(self, data: Optional[Dict[str, Any]] = None):
|
||||||
data = data or {}
|
data = data or {}
|
||||||
self.enabled: bool = bool(data.get("enabled", True))
|
self.enabled: bool = bool(data.get("enabled", True))
|
||||||
self.mode: str = str(data.get("mode", "predefined")) # predefined | tgpt (inactive by default)
|
# Mode principal: predefined (réponses) ou tgpt (IA)
|
||||||
self.tgpt_enabled: bool = bool(data.get("tgpt_enabled", False))
|
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_preprompt: str = str(data.get("tgpt_preprompt", "") or "")
|
||||||
self.tgpt_max_chars: int = int(data.get("tgpt_max_chars", 100))
|
self.tgpt_max_chars: int = int(data.get("tgpt_max_chars", 100))
|
||||||
self.cooldown_seconds: int = int(data.get("cooldown_seconds", 8))
|
self.cooldown_seconds: int = int(data.get("cooldown_seconds", 8))
|
||||||
@@ -174,12 +182,14 @@ class InteractionChatProcessor:
|
|||||||
get_registered_accounts: callable,
|
get_registered_accounts: callable,
|
||||||
get_account_policies: Optional[callable] = None,
|
get_account_policies: Optional[callable] = None,
|
||||||
send_message_as: callable,
|
send_message_as: callable,
|
||||||
|
create_clip_as: Optional[callable] = None,
|
||||||
storage: Optional[InteractionChatStorage] = None,
|
storage: Optional[InteractionChatStorage] = None,
|
||||||
):
|
):
|
||||||
self.channel_name = channel_name
|
self.channel_name = channel_name
|
||||||
self._get_registered_accounts = get_registered_accounts
|
self._get_registered_accounts = get_registered_accounts
|
||||||
self._get_account_policies = get_account_policies
|
self._get_account_policies = get_account_policies
|
||||||
self._send_message_as = send_message_as
|
self._send_message_as = send_message_as
|
||||||
|
self._create_clip_as = create_clip_as
|
||||||
self._storage = storage or InteractionChatStorage()
|
self._storage = storage or InteractionChatStorage()
|
||||||
|
|
||||||
self._config_file = "interaction_chat_config"
|
self._config_file = "interaction_chat_config"
|
||||||
@@ -330,11 +340,14 @@ class InteractionChatProcessor:
|
|||||||
matched_rule_id: Optional[str] = None
|
matched_rule_id: Optional[str] = None
|
||||||
responder_account: Optional[str] = None
|
responder_account: Optional[str] = None
|
||||||
rule_tgpt_preprompt: 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:
|
for r in cfg.rules:
|
||||||
if r.matches(username=username, message=content, mentioned_accounts=mentioned):
|
if r.matches(username=username, message=content, mentioned_accounts=mentioned):
|
||||||
response_text = (r.response_text or "").strip()
|
response_text = (r.response_text or "").strip()
|
||||||
matched_rule_id = r.id
|
matched_rule_id = r.id
|
||||||
|
rule_action = (r.action or "response").strip().lower()
|
||||||
if r.mention_account:
|
if r.mention_account:
|
||||||
ma = r.mention_account.strip()
|
ma = r.mention_account.strip()
|
||||||
if ma.startswith("@"):
|
if ma.startswith("@"):
|
||||||
@@ -342,6 +355,7 @@ class InteractionChatProcessor:
|
|||||||
responder_account = ma.lower() if ma else None
|
responder_account = ma.lower() if ma else None
|
||||||
if r.tgpt_preprompt:
|
if r.tgpt_preprompt:
|
||||||
rule_tgpt_preprompt = str(r.tgpt_preprompt)
|
rule_tgpt_preprompt = str(r.tgpt_preprompt)
|
||||||
|
rule_clip_has_delay = bool(getattr(r, "clip_has_delay", False))
|
||||||
break
|
break
|
||||||
|
|
||||||
# Choisir le compte expéditeur
|
# 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.
|
# 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 matched_rule_id is not None:
|
||||||
if not response_text:
|
action = (rule_action or "response").strip().lower()
|
||||||
self.append_log(
|
if action == "tgpt":
|
||||||
{
|
preprompt = (rule_tgpt_preprompt or cfg.tgpt_preprompt or "").strip()
|
||||||
"ts": _now_iso(),
|
cleaned_for_tgpt = _strip_registered_mentions(content, registered_accounts)
|
||||||
"type": "rule_empty_response",
|
response_text = self._tgpt_generate(preprompt=preprompt, message=cleaned_for_tgpt)
|
||||||
"channel": self.channel_name,
|
response_text = self._truncate(response_text, cfg.tgpt_max_chars)
|
||||||
"from": username,
|
if not response_text:
|
||||||
"content": content,
|
self.append_log(
|
||||||
"mentioned_accounts": mentioned,
|
{
|
||||||
"responder_account": responder_account,
|
"ts": _now_iso(),
|
||||||
"rule_id": matched_rule_id,
|
"type": "tgpt_empty",
|
||||||
}
|
"channel": self.channel_name,
|
||||||
)
|
"from": username,
|
||||||
continue
|
"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:
|
else:
|
||||||
if cfg.mode == "tgpt" and cfg.tgpt_enabled:
|
if cfg.mode == "tgpt" and cfg.tgpt_enabled:
|
||||||
preprompt = (rule_tgpt_preprompt or cfg.tgpt_preprompt or "").strip()
|
preprompt = (rule_tgpt_preprompt or cfg.tgpt_preprompt or "").strip()
|
||||||
@@ -409,6 +488,7 @@ class InteractionChatProcessor:
|
|||||||
"response": outgoing,
|
"response": outgoing,
|
||||||
"rule_id": matched_rule_id,
|
"rule_id": matched_rule_id,
|
||||||
"mode": cfg.mode,
|
"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}"}
|
||||||
|
|
||||||
+334
-23
@@ -17,6 +17,7 @@ from fonction.first_class import IA_generator, messageTwitch, storage
|
|||||||
from twitch_bot import chat_state
|
from twitch_bot import chat_state
|
||||||
from twitch_bot.controller import bot_controller
|
from twitch_bot.controller import bot_controller
|
||||||
from twitch_bot.interaction_chat import InteractionChatConfig
|
from twitch_bot.interaction_chat import InteractionChatConfig
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
def _first_enabled_user_index(config_path: str = "config/user.json") -> int:
|
def _first_enabled_user_index(config_path: str = "config/user.json") -> int:
|
||||||
@@ -30,6 +31,114 @@ def _first_enabled_user_index(config_path: str = "config/user.json") -> int:
|
|||||||
pass
|
pass
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json_file(path: str, default):
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data if data is not None else default
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _twitch_client_id() -> str:
|
||||||
|
cfg = _read_json_file("config/config.json", 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 _user_bearer_token(user_id: int) -> str:
|
||||||
|
users = _read_json_file("config/user.json", default=[])
|
||||||
|
if not isinstance(users, list) or user_id < 0 or user_id >= len(users):
|
||||||
|
raise ValueError("Utilisateur non trouvé")
|
||||||
|
u = users[user_id]
|
||||||
|
if not isinstance(u, dict):
|
||||||
|
raise ValueError("Utilisateur invalide")
|
||||||
|
raw = (u.get("tw_acc_token") or "").strip()
|
||||||
|
if raw.lower().startswith("oauth:"):
|
||||||
|
raw = raw.split(":", 1)[1].strip()
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("Token OAuth manquant")
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _twitch_helix_headers(user_bearer: str) -> dict:
|
||||||
|
client_id = _twitch_client_id()
|
||||||
|
if not client_id:
|
||||||
|
raise ValueError("Client-ID Twitch manquant. Ajoutez `twitch_client_id` dans config/config.json")
|
||||||
|
return {
|
||||||
|
"Client-ID": client_id,
|
||||||
|
"Authorization": f"Bearer {user_bearer}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _twitch_get_user_id_by_login(login: str, user_bearer: str) -> str:
|
||||||
|
login = (login or "").strip().lstrip("@")
|
||||||
|
if not login:
|
||||||
|
raise ValueError("Nom de chaîne requis")
|
||||||
|
|
||||||
|
headers = _twitch_helix_headers(user_bearer)
|
||||||
|
r = requests.get("https://api.twitch.tv/helix/users", headers=headers, params={"login": login}, timeout=10)
|
||||||
|
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 or {}).get("data") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(data, list) or not data:
|
||||||
|
raise ValueError("Chaîne Twitch introuvable (login)")
|
||||||
|
user = data[0] or {}
|
||||||
|
broadcaster_id = user.get("id")
|
||||||
|
if not broadcaster_id:
|
||||||
|
raise RuntimeError("Réponse Twitch invalide: id manquant")
|
||||||
|
return str(broadcaster_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _twitch_create_clip(broadcaster_id: str, user_bearer: str, has_delay: bool = False) -> dict:
|
||||||
|
headers = _twitch_helix_headers(user_bearer)
|
||||||
|
params = {"broadcaster_id": str(broadcaster_id), "has_delay": "true" if has_delay else "false"}
|
||||||
|
r = requests.post("https://api.twitch.tv/helix/clips", headers=headers, params=params, timeout=15)
|
||||||
|
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 = " (vérifiez que le token a le scope `clips:edit` et qu'il n'est pas expiré)"
|
||||||
|
raise RuntimeError(f"Erreur Twitch /helix/clips ({r.status_code}) {msg}{hint}".strip())
|
||||||
|
|
||||||
|
data = (payload or {}).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")
|
||||||
|
|
||||||
|
# URL publique approximative (le retour officiel donne surtout edit_url)
|
||||||
|
public_url = f"https://clips.twitch.tv/{clip_id}"
|
||||||
|
return {
|
||||||
|
"id": clip_id,
|
||||||
|
"edit_url": edit_url,
|
||||||
|
"url": public_url,
|
||||||
|
}
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config['SECRET_KEY'] = 'your-secret-key-here'
|
app.config['SECRET_KEY'] = 'your-secret-key-here'
|
||||||
socketio = SocketIO(app, cors_allowed_origins="*")
|
socketio = SocketIO(app, cors_allowed_origins="*")
|
||||||
@@ -47,12 +156,14 @@ def add_flux():
|
|||||||
data = request.json
|
data = request.json
|
||||||
channel_name = data.get('channel_name')
|
channel_name = data.get('channel_name')
|
||||||
record_audio = data.get('record_audio', True)
|
record_audio = data.get('record_audio', True)
|
||||||
|
send_messages = bool(data.get('send_messages', True))
|
||||||
|
enable_ia = bool(data.get('enable_ia', True))
|
||||||
|
|
||||||
if not channel_name:
|
if not channel_name:
|
||||||
return jsonify({'error': 'Nom du canal requis'}), 400
|
return jsonify({'error': 'Nom du canal requis'}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
flux_id = bot_controller.add_flux(channel_name, record_audio)
|
flux_id = bot_controller.add_flux(channel_name, record_audio, send_messages, enable_ia)
|
||||||
return jsonify({'success': True, 'flux_id': flux_id})
|
return jsonify({'success': True, 'flux_id': flux_id})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Erreur lors de l'ajout du flux: {str(e)}"
|
error_msg = f"Erreur lors de l'ajout du flux: {str(e)}"
|
||||||
@@ -129,9 +240,10 @@ def toggle_flux(flux_id):
|
|||||||
threading.Thread(target=bots['record_bot'].main, daemon=True).start()
|
threading.Thread(target=bots['record_bot'].main, daemon=True).start()
|
||||||
if bots['subtitle_bot']:
|
if bots['subtitle_bot']:
|
||||||
bots['subtitle_bot'].start_main_loop()
|
bots['subtitle_bot'].start_main_loop()
|
||||||
if bots['ia_bot']:
|
if bool(flux.get("enable_ia", True)) and bots.get('ia_bot'):
|
||||||
bots['ia_bot'].start_main_loop()
|
bots['ia_bot'].start_main_loop()
|
||||||
if bots['message_bot']:
|
# Respecter le flag par-flux send_messages
|
||||||
|
if bool(flux.get("send_messages", True)) and bool(flux.get("enable_ia", True)) and bots.get('message_bot'):
|
||||||
bots['message_bot'].start_loop_respond()
|
bots['message_bot'].start_loop_respond()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Erreur lors du redémarrage des bots: {e}")
|
print(f"Erreur lors du redémarrage des bots: {e}")
|
||||||
@@ -356,23 +468,99 @@ def delete_user(user_id):
|
|||||||
|
|
||||||
@app.route('/api/subtitles', methods=['GET'])
|
@app.route('/api/subtitles', methods=['GET'])
|
||||||
def get_subtitles():
|
def get_subtitles():
|
||||||
data = storage.read("subtitle_data")
|
flux_id = request.args.get("flux_id")
|
||||||
return jsonify(data)
|
channel = request.args.get("channel")
|
||||||
|
key = "subtitle_data"
|
||||||
|
if channel:
|
||||||
|
key = f"subtitle_data__{str(channel).strip().lower()}"
|
||||||
|
elif flux_id:
|
||||||
|
try:
|
||||||
|
fid = int(flux_id)
|
||||||
|
flux = next((f for f in bot_controller.flux_list if f.get("id") == fid), None)
|
||||||
|
if flux and flux.get("twitchname"):
|
||||||
|
key = f"subtitle_data__{str(flux.get('twitchname')).strip().lower()}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
data = storage.read(key)
|
||||||
|
return jsonify(data if isinstance(data, dict) else {})
|
||||||
|
|
||||||
|
@app.route('/api/subtitles/last', methods=['GET'])
|
||||||
|
def get_last_subtitle():
|
||||||
|
"""Retourne le dernier sous-titre (clé + texte)."""
|
||||||
|
try:
|
||||||
|
flux_id = request.args.get("flux_id")
|
||||||
|
channel = request.args.get("channel")
|
||||||
|
key = "subtitle_data"
|
||||||
|
if channel:
|
||||||
|
key = f"subtitle_data__{str(channel).strip().lower()}"
|
||||||
|
elif flux_id:
|
||||||
|
try:
|
||||||
|
fid = int(flux_id)
|
||||||
|
flux = next((f for f in bot_controller.flux_list if f.get("id") == fid), None)
|
||||||
|
if flux and flux.get("twitchname"):
|
||||||
|
key = f"subtitle_data__{str(flux.get('twitchname')).strip().lower()}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
subtitle_data = storage.read(key) or {}
|
||||||
|
if not isinstance(subtitle_data, dict) or not subtitle_data:
|
||||||
|
return jsonify({'success': True, 'last_key': None, 'last_subtitle': ''})
|
||||||
|
sorted_keys = sorted(subtitle_data.keys())
|
||||||
|
|
||||||
|
def _good_candidate(text: str) -> bool:
|
||||||
|
t = (text or "").strip()
|
||||||
|
if not t:
|
||||||
|
return False
|
||||||
|
# Heuristique: éviter les "bruits" très courts pour la génération IA
|
||||||
|
words = [w for w in t.split() if w]
|
||||||
|
return (len(t) >= 35) or (len(words) >= 8)
|
||||||
|
|
||||||
|
# Chercher le plus récent "utile", sinon fallback sur le tout dernier
|
||||||
|
picked_key = None
|
||||||
|
picked_text = ""
|
||||||
|
for k in reversed(sorted_keys):
|
||||||
|
t = subtitle_data.get(k, "")
|
||||||
|
if _good_candidate(str(t)):
|
||||||
|
picked_key = k
|
||||||
|
picked_text = str(t)
|
||||||
|
break
|
||||||
|
|
||||||
|
if picked_key is None:
|
||||||
|
picked_key = sorted_keys[-1]
|
||||||
|
picked_text = str(subtitle_data.get(picked_key, "") or "")
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'last_key': picked_key, 'last_subtitle': picked_text})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
@app.route('/api/subtitles/clear', methods=['POST'])
|
@app.route('/api/subtitles/clear', methods=['POST'])
|
||||||
def clear_subtitles():
|
def clear_subtitles():
|
||||||
"""Nettoyer l'historique des sous-titres"""
|
"""Nettoyer l'historique des sous-titres"""
|
||||||
try:
|
try:
|
||||||
|
flux_id = request.args.get("flux_id")
|
||||||
|
channel = request.args.get("channel")
|
||||||
|
storage_key = "subtitle_data"
|
||||||
|
if channel:
|
||||||
|
storage_key = f"subtitle_data__{str(channel).strip().lower()}"
|
||||||
|
elif flux_id:
|
||||||
|
try:
|
||||||
|
fid = int(flux_id)
|
||||||
|
flux = next((f for f in bot_controller.flux_list if f.get("id") == fid), None)
|
||||||
|
if flux and flux.get("twitchname"):
|
||||||
|
storage_key = f"subtitle_data__{str(flux.get('twitchname')).strip().lower()}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Récupérer toutes les clés de sous-titres
|
# Récupérer toutes les clés de sous-titres
|
||||||
subtitle_data = storage.read("subtitle_data")
|
subtitle_data = storage.read(storage_key)
|
||||||
|
|
||||||
# Supprimer chaque clé une par une
|
# Supprimer chaque clé une par une
|
||||||
for key in list(subtitle_data.keys()):
|
for loop_key in list((subtitle_data or {}).keys()):
|
||||||
storage.delete("subtitle_data", key)
|
storage.delete(storage_key, loop_key)
|
||||||
|
|
||||||
# Supprimer également le fichier JSON s'il existe
|
# Supprimer également le fichier JSON s'il existe
|
||||||
storage_dir = "storage"
|
storage_dir = "storage"
|
||||||
subtitle_file = os.path.join(storage_dir, "subtitle_data.json")
|
subtitle_file = os.path.join(storage_dir, f"{storage_key}.json")
|
||||||
if os.path.exists(subtitle_file):
|
if os.path.exists(subtitle_file):
|
||||||
os.remove(subtitle_file)
|
os.remove(subtitle_file)
|
||||||
|
|
||||||
@@ -386,6 +574,74 @@ def clear_subtitles():
|
|||||||
'error': f'Erreur lors du nettoyage: {str(e)}'
|
'error': f'Erreur lors du nettoyage: {str(e)}'
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/subtitles/rules/config', methods=['GET'])
|
||||||
|
def get_subtitle_rules_config():
|
||||||
|
try:
|
||||||
|
processor = None
|
||||||
|
for flux_id, bots in bot_controller.bots.items():
|
||||||
|
processor = bots.get('subtitle_rules')
|
||||||
|
if processor:
|
||||||
|
break
|
||||||
|
if not processor:
|
||||||
|
from twitch_bot.subtitle_rules import SubtitleRulesStorage, SubtitleRulesConfig
|
||||||
|
st = SubtitleRulesStorage()
|
||||||
|
cfg = SubtitleRulesConfig(st.read_json("subtitle_rules_config", default={}))
|
||||||
|
data = cfg.to_dict()
|
||||||
|
else:
|
||||||
|
data = processor.load_config().to_dict()
|
||||||
|
return jsonify({'success': True, 'config': data})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/subtitles/rules/config', methods=['POST'])
|
||||||
|
def save_subtitle_rules_config():
|
||||||
|
try:
|
||||||
|
payload = request.json or {}
|
||||||
|
config_dict = payload.get('config') or {}
|
||||||
|
processor = None
|
||||||
|
for flux_id, bots in bot_controller.bots.items():
|
||||||
|
processor = bots.get('subtitle_rules')
|
||||||
|
if processor:
|
||||||
|
break
|
||||||
|
if processor:
|
||||||
|
cfg = processor.save_config(config_dict)
|
||||||
|
data = cfg.to_dict()
|
||||||
|
else:
|
||||||
|
from twitch_bot.subtitle_rules import SubtitleRulesStorage, SubtitleRulesConfig
|
||||||
|
st = SubtitleRulesStorage()
|
||||||
|
cfg = SubtitleRulesConfig(config_dict)
|
||||||
|
st.write_json("subtitle_rules_config", cfg.to_dict())
|
||||||
|
data = cfg.to_dict()
|
||||||
|
return jsonify({'success': True, 'config': data})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/subtitles/rules/log', methods=['GET'])
|
||||||
|
def get_subtitle_rules_log():
|
||||||
|
try:
|
||||||
|
limit = int(request.args.get('limit', '120'))
|
||||||
|
limit = max(1, min(500, limit))
|
||||||
|
processor = None
|
||||||
|
for flux_id, bots in bot_controller.bots.items():
|
||||||
|
processor = bots.get('subtitle_rules')
|
||||||
|
if processor:
|
||||||
|
break
|
||||||
|
if processor:
|
||||||
|
logs = processor.read_log(limit=limit)
|
||||||
|
else:
|
||||||
|
from twitch_bot.subtitle_rules import SubtitleRulesStorage
|
||||||
|
st = SubtitleRulesStorage()
|
||||||
|
logs = st.read_json("subtitle_rules_log", default=[])
|
||||||
|
if not isinstance(logs, list):
|
||||||
|
logs = []
|
||||||
|
logs = logs[-limit:]
|
||||||
|
return jsonify({'success': True, 'logs': logs})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
@app.route('/api/subtitles/process', methods=['POST'])
|
@app.route('/api/subtitles/process', methods=['POST'])
|
||||||
def process_subtitles():
|
def process_subtitles():
|
||||||
"""Lancer manuellement le traitement des sous-titres"""
|
"""Lancer manuellement le traitement des sous-titres"""
|
||||||
@@ -509,10 +765,6 @@ def send_message():
|
|||||||
if not message:
|
if not message:
|
||||||
return jsonify({'error': 'Message requis'}), 400
|
return jsonify({'error': 'Message requis'}), 400
|
||||||
|
|
||||||
# Vérifier si l'envoi de messages est activé
|
|
||||||
if not chat_state.chat_messages_enabled:
|
|
||||||
return jsonify({'error': 'Envoi de messages désactivé'}), 403
|
|
||||||
|
|
||||||
# Trouver le bot de message pour ce canal
|
# Trouver le bot de message pour ce canal
|
||||||
try:
|
try:
|
||||||
msg_bot = messageTwitch("config/user.json", channel)
|
msg_bot = messageTwitch("config/user.json", channel)
|
||||||
@@ -573,10 +825,6 @@ def send_chat_message(flux_id):
|
|||||||
if not message:
|
if not message:
|
||||||
return jsonify({'error': 'Message requis'}), 400
|
return jsonify({'error': 'Message requis'}), 400
|
||||||
|
|
||||||
# Vérifier si l'envoi de messages est activé
|
|
||||||
if not chat_state.chat_messages_enabled:
|
|
||||||
return jsonify({'error': 'Envoi de messages désactivé'}), 403
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Trouver le flux
|
# Trouver le flux
|
||||||
flux = None
|
flux = None
|
||||||
@@ -587,6 +835,9 @@ def send_chat_message(flux_id):
|
|||||||
|
|
||||||
if not flux:
|
if not flux:
|
||||||
return jsonify({'error': 'Flux non trouvé'}), 404
|
return jsonify({'error': 'Flux non trouvé'}), 404
|
||||||
|
|
||||||
|
if not bool(flux.get("send_messages", True)):
|
||||||
|
return jsonify({'error': 'Envoi de messages désactivé pour ce flux'}), 403
|
||||||
|
|
||||||
# Envoyer le message avec l'utilisateur spécifié
|
# Envoyer le message avec l'utilisateur spécifié
|
||||||
msg_bot = messageTwitch("config/user.json", flux['twitchname'])
|
msg_bot = messageTwitch("config/user.json", flux['twitchname'])
|
||||||
@@ -998,12 +1249,6 @@ def auto_message_loop():
|
|||||||
|
|
||||||
while auto_message_running:
|
while auto_message_running:
|
||||||
try:
|
try:
|
||||||
# Vérifier si l'envoi de messages est activé
|
|
||||||
if not chat_state.chat_messages_enabled:
|
|
||||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] Envoi de messages désactivé, attente...")
|
|
||||||
time.sleep(5)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Vérifier s'il y a des générations disponibles
|
# Vérifier s'il y a des générations disponibles
|
||||||
generation_data = storage.read("IA_generator")
|
generation_data = storage.read("IA_generator")
|
||||||
if not generation_data:
|
if not generation_data:
|
||||||
@@ -1102,6 +1347,72 @@ def get_system_status():
|
|||||||
"""Obtenir le statut de tous les composants"""
|
"""Obtenir le statut de tous les composants"""
|
||||||
return jsonify(bot_controller.get_system_status())
|
return jsonify(bot_controller.get_system_status())
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/clips/create', methods=['POST'])
|
||||||
|
def create_clip():
|
||||||
|
"""
|
||||||
|
Créer un clip Twitch via Helix.
|
||||||
|
Nécessite:
|
||||||
|
- `twitch_client_id` dans config/config.json
|
||||||
|
- un user token avec scope `clips:edit` (config/user.json, champ tw_acc_token)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
channel_login = (data.get("channel_login") or "").strip()
|
||||||
|
user_id = int(data.get("user_id", _first_enabled_user_index()))
|
||||||
|
has_delay = bool(data.get("has_delay", False))
|
||||||
|
|
||||||
|
bearer = _user_bearer_token(user_id)
|
||||||
|
broadcaster_id = _twitch_get_user_id_by_login(channel_login, bearer)
|
||||||
|
clip = _twitch_create_clip(broadcaster_id=broadcaster_id, user_bearer=bearer, has_delay=has_delay)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"clip": {
|
||||||
|
"broadcaster_login": channel_login.lstrip("@"),
|
||||||
|
"broadcaster_id": broadcaster_id,
|
||||||
|
**clip,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 400
|
||||||
|
except RuntimeError as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 502
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# === CLIPS AUTOMATION (stub for future) ===
|
||||||
|
_clips_automation_running = False
|
||||||
|
_clips_automation_mode = "manual"
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/clips/automation/status', methods=['GET'])
|
||||||
|
def clip_automation_status():
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"running": _clips_automation_running,
|
||||||
|
"mode": _clips_automation_mode,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/clips/automation/start', methods=['POST'])
|
||||||
|
def clip_automation_start():
|
||||||
|
# Intentionally not implemented yet: future hook point.
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "Automatisation clips: pas encore implémentée (stub prêt)",
|
||||||
|
}), 501
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/clips/automation/stop', methods=['POST'])
|
||||||
|
def clip_automation_stop():
|
||||||
|
# Intentionally not implemented yet: future hook point.
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "Automatisation clips: pas encore implémentée (stub prêt)",
|
||||||
|
}), 501
|
||||||
|
|
||||||
@app.route('/api/interaction/config', methods=['GET'])
|
@app.route('/api/interaction/config', methods=['GET'])
|
||||||
def get_interaction_config():
|
def get_interaction_config():
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user