87 Commits
Author SHA1 Message Date
Mow910andClaude Sonnet 5 2cf0340a26 Retire le fichier "instance" du suivi git
Ce fichier entrait en conflit avec le dossier instance/ (base SQLite +
config persistante), gitignoré, et causait "unable to open database
file" au démarrage après un git pull sur les déploiements existants.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 19:07:14 +02:00
Mow910 89845b3248 Add Steam free games feed to FreeLoot 2026-08-17 19:01:56 +02:00
Mow910 c962f57031 file missing 2026-08-17 14:46:35 +02:00
Mow910 3da5ee1dab Recover persisted auto rooms during voice events 2026-08-16 22:27:06 +02:00
Mow910 7d57b82b29 Harden auto room lifecycle and persistence 2026-08-16 22:18:57 +02:00
Mow910 ae6d491c44 Prevent repeated rules acceptance role changes 2026-08-16 22:01:11 +02:00
Mow910 8eb6917337 Improve YouTube history sorting and filters 2026-08-16 21:55:18 +02:00
Mow910 e22f7b2d0f Notify only the latest YouTube video 2026-08-16 21:28:25 +02:00
Mow910 7fcb196bf3 Fix YouTube notification delivery 2026-08-16 21:13:40 +02:00
Mow910 e23f0a0385 Refactor rules acknowledgment logic and update role assignment behavior
- Removed the `on_presentation_message` function to streamline role assignment processes.
- Updated the `assign_rules_arrival_on_join` function to clarify its purpose, focusing solely on assigning the arrival role upon member join.
- Adjusted success message generation for role validation to enhance clarity and user experience.
- Modified configuration settings and web templates to reflect the updated role assignment behavior, ensuring accurate user guidance.
2026-05-04 13:38:07 +02:00
Mow910 4e6f8bec3a Add automatic role assignment on member join for rules acknowledgment
- Introduced a new function `assign_rules_arrival_on_join` to assign the arrival role to members upon joining, based on configuration settings.
- Updated the `on_member_join` event to call the new function, enhancing the user experience by providing immediate role assignment.
- Modified configuration options in the web application to allow enabling or disabling this feature.
- Updated the configurations template to reflect the new role assignment behavior, ensuring clarity for users.
2026-05-04 13:31:11 +02:00
Mow910 8634f946a1 Refactor rules acknowledgment handling in Discord bot
- Introduced a new helper function `_rules_ack_success_text` to streamline the success message generation for role acknowledgment.
- Enhanced error handling in `handle_rules_accept` to ensure proper member fetching and context validation.
- Simplified the success message response logic, improving code readability and maintainability.
2026-05-04 13:27:32 +02:00
Mow910 69d8167581 Implement rules acknowledgment feature in Discord bot
- Added a new module for managing rules acknowledgment, including a persistent button for users to accept the rules.
- Updated the database schema to change the `value` column type in the `Configuration` model to `TEXT` for better handling of longer rule descriptions.
- Enhanced the web application to include configuration options for rules acknowledgment, allowing customization of the rules message and button label.
- Integrated the rules acknowledgment feature into the Discord bot, enabling automatic role assignment upon acceptance and handling of presentation messages for validated roles.
- Updated the configurations template to support new settings related to rules acknowledgment.
2026-05-04 13:17:04 +02:00
Mow910 e72647612d Update moderation command permissions in Discord bot
- Changed permissions for the `/ban`, `/kick`, and `/timeout` commands to require `manage_messages` instead of their previous specific permissions.
- Updated context menu commands for banning, kicking, and timing out authors to align with the new permission structure, ensuring consistent moderation capabilities across commands.
2026-05-03 19:01:08 +02:00
Mow910 0c3048660a Remove deprecated context menu command for author inspection in moderation module
- Eliminated the `Inspecter l'auteur` context menu command due to Discord's limitation on global message commands.
- Updated related documentation to reflect the removal of this command, ensuring clarity for users regarding available moderation functionalities.
2026-05-03 18:44:13 +02:00
Mow910 9d840e9bde Enhance moderation functionality in Discord bot
- Added new moderation commands: `/warn` and `/inspect`, improving user interaction for warnings and user inspections.
- Refactored the `send_warning_confirmation` function to streamline the warning process and improve message formatting.
- Updated command handling to ensure proper user feedback and error handling during moderation actions.
- Improved the overall structure of moderation-related functions for better maintainability.
2026-05-03 18:22:35 +02:00
Mow910 769251df0d Enhance moderation commands and ProtonDB integration in Discord bot
- Introduced a new slash command `/say` for sending messages in specific channels, improving moderation capabilities.
- Updated the ProtonDB command to include an alias `/pdb`, streamlining user interaction.
- Refactored command descriptions and improved error handling for better user experience.
2026-05-03 18:05:50 +02:00
Mow910 f901db1a7d Implement ProtonDB command integration in Discord bot
- Replaced the deprecated ProtonDB command handling with a new slash command and context menu for improved user experience.
- Updated moderation command descriptions to reflect the new ProtonDB functionalities.
- Introduced a new module for ProtonDB interactions, enhancing the bot's capability to search and display game compatibility information.
2026-05-03 14:48:16 +02:00
Mow910 2b8b725bbb Refactor moderation commands in Discord bot
- Removed deprecated timeout and ban command handling from the message processing logic.
- Added new moderation slash commands for ban, kick, and timeout functionalities.
- Updated the command tree initialization to include the new moderation commands for better organization and usability.
2026-05-03 14:36:44 +02:00
Mow910 3e57accb11 fix: update message formatting in Humble Bundle notification
- Removed the mention of "@here" from the notification message to streamline the announcement format.
- Ensured the message remains clear and informative for users regarding the available game bundle.
2026-05-03 14:25:08 +02:00
Mow910andCursor ecf59d645e fix: ne pas effacer les rôles modération si le formulaire n’inclut pas la liste (bot pas prêt)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 14:20:18 +02:00
Mow910 ec296dd226 Refactor Discord member statistics handling and remove unused components
- Removed the `guild_member_stats` table and related logic from the database and models, as well as the associated webapp routes and templates.
- Cleaned up the Discord bot initialization by eliminating unused imports and functions related to member statistics.
- Updated logging configuration in `run-web.py` to streamline the startup process and improve thread management.
2026-03-25 23:13:14 +01:00
Mow910 b51430e2b2 Add rate limit logging and enhance Discord bot initialization
- Introduced a new module `rate_limit_logging.py` to log HTTP headers for Discord API rate limits (HTTP 429).
- Updated the Discord bot initialization in `__init__.py` to include a custom HTTP trace configuration for better rate limit handling.
- Adjusted logging levels in `run-web.py` to reduce verbosity for rate limit headers.
2026-03-25 23:10:02 +01:00
Mow910 3c7d7f4e80 Enhance logging configuration and thread management in run-web.py
- Added logic to prevent duplicate logging from Discord-related modules by clearing their handlers and ensuring propagation.
- Refactored thread management to start the Discord bot first, followed by the web server and Twitch bot with a delay to avoid concurrency issues at startup.
2026-03-25 23:03:32 +01:00
Mow910 10923f58c3 patch 2026-03-25 22:58:06 +01:00
Mow910 855c13c183 Add guild_member_stats table and update permissions for Discord members
- Created a new table `guild_member_stats` to track message counts and voice activity for users in each guild.
- Updated the database schema and models to include the new table.
- Added a new permission entry for `discord_members` in the `webapp_page_permission` table.
- Enhanced the webapp to include links and settings for managing Discord member statistics.
2026-03-25 22:52:32 +01:00
Mow910 bb92c3dd92 Enhance shoutbox functionality and UI in popout template
- Added buttons to adjust font size and toggle sound notifications in the shoutbox.
- Updated sound notification logic to improve user experience when new messages arrive.
- Refactored font size handling to persist user preferences using local storage.
- Improved layout for better responsiveness and usability across devices.
2026-03-25 22:30:58 +01:00
Mow910 c4dab1d873 Refactor Twitch moderation template for improved layout and responsiveness
- Adjusted the layout of the Twitch moderation template to enhance visual clarity and responsiveness, including changes to element sizes and spacing.
- Updated the display of live status and link filter sections for a more compact and streamlined appearance.
- Improved text sizes and alignment for better readability across different devices.
2026-03-25 22:09:40 +01:00
Mow910 9cb4186bb8 Add stream uptime formatting to Twitch moderation and enhance template layout
- Introduced a new helper function `_format_stream_uptime` to calculate and format the stream uptime based on the start time.
- Updated the Twitch moderation route to include the formatted stream uptime in the rendered template.
- Enhanced the Twitch moderation template layout for better responsiveness and visual clarity, including adjustments to the live status display and overall structure.
2026-03-25 22:00:29 +01:00
Mow910 f90c8eed81 Update ProtonDB template to include Twitch configuration option for enabling features
- Modified the conditional check in the ProtonDB template to allow for enabling features based on both ProtonDB and Twitch settings.
- This change enhances the flexibility of the configuration for users integrating ProtonDB with Twitch.
2026-03-24 00:31:17 +01:00
Mow910 fc274b47ae Implement cooldown feature for ProtonDB command in TwitchBot and update configuration options in webapp
- Added a cooldown mechanism to the ProtonDB command to prevent spamming.
- Introduced a new configuration option for setting the cooldown duration in seconds.
- Updated the ProtonDB template to include the new cooldown setting for Twitch users.
2026-03-24 00:21:02 +01:00
Mow910 a93d5cda70 Refactor ProtonDB command execution in TwitchBot for improved context handling
- Introduced a helper function `_search` to encapsulate the ProtonDB search logic within the app context.
- Updated the command execution to use the new helper function, enhancing code clarity and maintainability.
2026-03-24 00:02:49 +01:00
Mow910 c9f27bf09e Add ProtonDB command support for Twitch and update configuration options
- Introduced `cmd_pdb` command in TwitchBot for ProtonDB integration.
- Registered new command for Twitch and updated chat command list.
- Added configuration options for enabling ProtonDB on Twitch in the webapp.
- Updated ProtonDB template to include new settings and permissions for Twitch users.
2026-03-24 00:00:05 +01:00
Mow910 0453af255f Refactor Twitch moderation template layout by adjusting grid column spans for improved responsiveness. Removed specific column span classes to allow for a more flexible design, enhancing the overall user experience on various screen sizes. 2026-03-18 20:49:37 +01:00
Mow910 26c9d0dc06 back 2026-03-18 20:47:44 +01:00
Mow910 c265149357 Enhance Twitch moderation template layout by introducing a new responsive design for larger screens. Added a 'twitch-wide-layout' class to improve overall layout and grid structure, ensuring better usability on wider displays. 2026-03-18 20:45:38 +01:00
Mow910 47bc7146de Update Twitch moderation template to improve grid responsiveness by changing column classes to 2xl and adjusting heights. Also, increase default shoutbox font size for better readability. 2026-03-18 20:42:26 +01:00
Mow910 f4c9fa2138 Update Twitch moderation template layout to enhance responsiveness by adjusting grid classes and increasing column heights. This improves the overall user experience and visual consistency across different screen sizes. 2026-03-18 20:40:08 +01:00
Mow910 245aaf9c6d Enhance TwitchBot connection handling by implementing a reconnection strategy that restarts the session after 90 seconds of disconnection. Update logging messages for clarity. Modify Twitch moderation template layout for improved responsiveness and user experience, adjusting grid classes and simplifying live status display. 2026-03-18 20:37:29 +01:00
Mow910 6ef0b6856e Enhance Twitch and Discord bot functionality by updating BOT_STATUS management. Added tracking for Twitch message timestamps and messages per minute, improved connection handling, and refined live status updates. Updated webapp to reflect new status attributes and modified Twitch moderation template for better chat message handling. 2026-03-18 20:31:40 +01:00
Mow910 af86ba9a8e Refactor TwitchBot event loop management by replacing get_event_loop with get_running_loop for improved compatibility with asynchronous operations. This change enhances the bot's readiness handling and ensures consistent event loop usage. 2026-03-10 20:11:13 +01:00
Mow910 399b7c42fd Updated Twitch moderation template to use JSON formatting for lastLogTimestamp, shoutboxCurrentUser, and _streamStartedAt variables, enhancing data handling and security in JavaScript context. 2026-03-10 20:07:39 +01:00
Mow910 ce5f60a2d7 Enhance TwitchBot message sending by integrating event loop management. Updated send_twitch_message to utilize the existing event loop for asynchronous message sending, improving reliability and error handling. Added timeout handling for message sending operations. 2026-03-10 19:59:44 +01:00
Mow910 1bf24d299c Updated the current username variable in the Twitch moderation template to use JSON formatting for improved data handling and security. This change enhances the integration of user data within the JavaScript context. 2026-03-10 19:36:55 +01:00
Mow910 c8c1e0c283 Refactor chat message deletion methods in moderation and link filter modules. Updated to use delete_chat_message instead of delete_chat_messages for improved clarity and consistency in message handling. 2026-03-10 18:45:55 +01:00
Mow910 d7b78ec1c4 Ajout du nom d'utilisateur actuel dans les messages du bot dans la shoutbox, améliorant ainsi la personnalisation et la clarté des interactions. Mise à jour de l'affichage des messages pour inclure le nom d'utilisateur à côté de l'étiquette "Bot". 2026-03-10 18:29:31 +01:00
Mow910 6d364f28f1 Ajout de boutons de modération dans la shoutbox pour les modérateurs Twitch, permettant de supprimer des messages, de mettre en timeout ou de bannir des utilisateurs directement depuis l'interface. Mise à jour de la logique de journalisation pour inclure le nom de l'administrateur au lieu de "WebApp". Amélioration de l'expérience utilisateur avec des prompts pour la durée et la raison des actions de modération. 2026-03-10 18:25:55 +01:00
Mow910 589cb1f428 Ajout d'un bouton de notification pour les nouveaux messages dans la shoutbox, permettant aux modérateurs Twitch de voir facilement les messages non lus. Mise à jour de la logique de défilement automatique et gestion des compteurs de messages non lus. Amélioration de l'interface utilisateur pour une meilleure expérience de modération. 2026-03-10 18:02:55 +01:00
Mow910 c44e624f53 Amélioration de la gestion de l'historique des vidéos YouTube. Ajout de l'enregistrement de toutes les vidéos détectées dans l'historique, tout en simplifiant la logique de notification pour éviter les doublons. Mise à jour des conditions de notification pour garantir que les vidéos sont correctement enregistrées et notifiées. 2026-03-09 20:20:28 +01:00
Mow910 2e83096550 Ajout d'un historique des vidéos YouTube dans la base de données et l'interface utilisateur. Création de la table youtube_video_history pour stocker les vidéos détectées, avec des fonctionnalités pour afficher l'historique et forcer l'envoi de notifications Discord. Mise à jour des modèles, des routes et des templates pour intégrer cette nouvelle fonctionnalité. 2026-03-09 18:31:27 +01:00
Mow910 540d23a3cf Ajout de la gestion des publications Patreon dans la base de données. Création de la table patreon_post avec les colonnes nécessaires et mise à jour des migrations pour intégrer ces nouvelles colonnes. Intégration de la vérification des publications Patreon dans le bot Discord et ajout de liens vers Patreon dans l'interface utilisateur. 2026-03-07 11:01:58 +01:00
Mow910 a2ade9deeb Ajout d'une fonctionnalité de nettoyage des salons vocaux orphelins au démarrage du bot. Implémentation de la fonction cleanup_orphaned_auto_rooms pour supprimer les salons vides qui ne sont plus utilisés, améliorant ainsi la gestion des ressources dans les serveurs Discord. Mise à jour de la logique de création de salons pour éviter la suppression des salons actifs. 2026-03-07 10:42:31 +01:00
Mow910 eedc7e4203 Refactor author icon handling in YouTube notifications. Updated the logic to strip whitespace from the author icon URL and ensure it starts with a valid HTTP/HTTPS scheme. This change improves the robustness of the embed author icon feature in both the Discord bot and web application. 2026-03-07 00:49:20 +01:00
Mow910 b8ead19360 Ajout de fonctionnalités de personnalisation dans la shoutbox pour les modérateurs Twitch. Implémentation de la gestion de la taille de la police et d'un bouton pour activer/désactiver le son des notifications. Mise à jour de l'interface utilisateur pour améliorer l'expérience de modération. 2026-03-07 00:06:07 +01:00
Mow910 64c043feb5 Ajout de fonctionnalités de mention dans la shoutbox pour les modérateurs Twitch. Implémentation de la gestion des mentions d'utilisateurs avec un système de mise en surbrillance pour les messages contenant des mentions. Mise à jour de l'interface utilisateur pour permettre aux modérateurs de mentionner facilement d'autres utilisateurs et d'améliorer l'interaction dans la shoutbox. 2026-03-06 23:59:04 +01:00
Mow910 10dd78f630 Ajout de la gestion des informations de stream dans le bot Twitch. Implémentation de la fonction de remplacement des variables de commande pour inclure des informations dynamiques telles que le titre du stream, le nom du jeu, le nombre de viewers et l'uptime. Mise à jour de l'interface utilisateur pour afficher ces informations en temps réel dans la modération Twitch. 2026-03-06 13:48:43 +01:00
Mow910 876eb1a080 Ajout d'un système de gestion des utilisateurs en ligne dans la shoutbox pour les modérateurs Twitch. Implémentation d'un endpoint de heartbeat pour suivre la présence des modérateurs et mise à jour de l'interface utilisateur pour afficher la liste des utilisateurs en ligne. Amélioration de l'expérience utilisateur avec un bouton pour ouvrir la shoutbox en popout. 2026-03-05 00:08:50 +01:00
Mow910 dc14b5193f Ajout d'une fonctionnalité de notification sonore dans la shoutbox pour les modérateurs Twitch. Implémentation d'un système de détection de visibilité de l'onglet et d'un son de notification lorsque l'onglet n'est pas actif. Cela améliore l'expérience utilisateur en alertant les modérateurs des nouveaux messages même lorsqu'ils ne regardent pas la shoutbox. 2026-03-04 22:40:38 +01:00
Mow910 137b6942ae Ajout d'un système de shoutbox pour les modérateurs Twitch. Création d'un modèle de base de données pour les messages de shoutbox et implémentation d'endpoints pour envoyer, récupérer et effacer les messages. Mise à jour de l'interface utilisateur pour afficher les messages et permettre l'envoi via un formulaire. 2026-03-04 22:30:56 +01:00
Mow910 77c3c11556 Ajout de la gestion de l'édition des commandes Twitch et des logs de modération. Implémentation d'un endpoint pour modifier les commandes existantes avec validation des données et gestion des permissions. Ajout d'un endpoint pour récupérer les logs de modération récents, améliorant ainsi la traçabilité des actions. Mise à jour de l'interface pour intégrer un système de notifications et des animations. 2026-03-04 19:38:05 +01:00
Mow910 4eb7f304dc Merge pull request #9 from Mow910/discord-transfert-msg
Amélioration de la gestion des permissions dans les salons vocaux aut…
2026-02-17 18:18:12 +01:00
Mow910 d24be973b8 Amélioration de la gestion des permissions dans les salons vocaux automatiques. Préservation des permissions existantes pour le rôle "everyone" lors de l'application des modes d'accès, et mise à jour des overwrites pour les membres en fonction des listes blanche et noire. Cela permet une gestion plus fine des accès selon le mode sélectionné. 2026-02-17 17:56:07 +01:00
Mow910 5ee5c16cf3 Merge pull request #8 from Mow910/discord-transfert-msg
Ajout de la gestion des messages dans les salons automatiques avec la…
2026-02-17 17:46:14 +01:00
Mow910 5e4af406ff Ajout de la gestion des messages dans les salons automatiques avec la fonction on_message_auto_rooms. Mise à jour des emojis pour les modes d'accès et amélioration de l'embed de configuration des salons vocaux, incluant des sections pour les listes blanche et noire, ainsi que des informations sur le propriétaire et les modes d'accès. Correction de messages d'erreur et ajustements divers pour une meilleure expérience utilisateur. 2026-02-17 17:42:51 +01:00
Mow910 ed0bd4b661 Merge pull request #7 from Mow910/discord-transfert-msg
Fix: Corriger l'endpoint url_for dans youtube.html
2026-02-17 15:06:12 +01:00
Mow910 4d8038cc77 Fix: Corriger l'endpoint url_for dans youtube.html 2026-02-17 15:05:05 +01:00
Mow910 942b23c956 Merge pull request #6 from Mow910/discord-transfert-msg
Ajout de la sélection de tags pour les posts dans les forums lors du …
2026-02-14 20:33:44 +01:00
Mow910 651773e63d Ajout de la sélection de tags pour les posts dans les forums lors du transfert de messages. Mise à jour de la classe TransferReasonModal pour inclure les tags sélectionnés et ajout d'une nouvelle classe ForumTagSelect pour gérer la sélection des tags. Amélioration de l'interaction utilisateur avec des messages contextuels pour la sélection des tags. 2026-02-14 20:15:08 +01:00
Mow910 3450cd031c Merge pull request #5 from Mow910/discord-transfert-msg
Ajout de la commande contextuelle "Déplacer le message" pour transfér…
2026-02-14 19:37:32 +01:00
Mow910 60b90edcb3 Ajout de la commande contextuelle "Déplacer le message" pour transférer des messages entre canaux, avec gestion des forums et des threads. Intégration d'un modal pour spécifier la raison du transfert et mise à jour de la synchronisation des commandes d'application. 2026-02-14 19:36:39 +01:00
Mow910 252e169af5 Merge pull request #4 from Mow910/discord-transfert-msg
Discord transfert msg
2026-02-14 18:34:03 +01:00
Mow910 179876d2ce Raison pour le titre du post dans le forum 2026-02-14 18:32:32 +01:00
Mow910 830ce61796 sans com 2026-02-14 18:28:03 +01:00
Mow910 5709b1c0a3 Amélioration de la commande !transfert pour supporter les canaux textuels, threads et forums. Ajout de vérifications de compatibilité pour le canal de destination et mise à jour de la documentation des commandes. Gestion des transferts de messages avec création de posts dans les forums et envoi via webhooks pour les autres types de canaux. 2026-02-14 18:01:31 +01:00
Mow910 11348bda39 Ajout de la commande !transfert pour transférer des messages entre canaux tout en préservant l'identité de l'auteur. Mise à jour de la documentation des commandes pour inclure cette nouvelle fonctionnalité et ses alias (!transfer, !move). 2026-02-14 16:55:52 +01:00
Mow910 f7e85bac69 Merge pull request #3 from Mow910/fix/twitch-auth-asyncio
Fix: Retrait de asyncio.run() pour le constructeur Twitch
2026-02-12 23:49:21 +01:00
Mow910andCursor d1d4e3b5a5 Fix: Retrait de asyncio.run() pour le constructeur Twitch
Le constructeur de la classe Twitch n'est pas une coroutine et ne doit pas être appelé avec asyncio.run(). Cela causait une ValueError lors de la demande de token Twitch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 23:47:50 +01:00
Mow910andCursor 5aa52c8137 Merge branch 'twitchbot' into main
Intégration de toutes les fonctionnalités développées dans la branche twitchbot :
- Système de modération Twitch complet
- Filtre de liens intelligent
- Notifications d'événements Twitch
- Système Freeloot Discord
- Salons automatiques Discord
- Authentification utilisateur
- Gestion des utilisateurs
- Interfaces d'administration étendues

Conflits résolus en faveur des versions de twitchbot pour :
- templates/commandes.html
- templates/configurations.html
- templates/humeurs.html
- templates/index.html
- templates/live-alert.html
- templates/moderation.html
- templates/protondb.html
- templates/template.html

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 22:40:27 +01:00
Mow910andCursor b274dcaa51 Ajout de fonctionnalités majeures Twitch, Discord et interface web
Nouvelles fonctionnalités :
- Système de modération Twitch complet (bans, timeouts, avertissements)
- Filtre de liens intelligent pour Twitch avec whitelist/blacklist
- Notifications d'événements Twitch (follows, subs, raids, etc.)
- Système Freeloot Discord avec flux RSS dédié
- Salons automatiques Discord (création/suppression dynamique)
- Authentification utilisateur pour l'interface web (login/register)
- Gestion des utilisateurs et permissions
- Interface de modération Twitch dans la webapp
- Interface de gestion des événements Twitch
- Configuration des paramètres utilisateur
- Migration BDD pour les mots bannis

Améliorations de l'interface :
- Refonte complète des templates (configurations, commandes, humeurs, etc.)
- Nouvelle page de settings utilisateur
- Page de gestion des utilisateurs (admin)
- Page d'erreur 403 personnalisée
- Amélioration de la navigation et du design global
- Intégration de nouvelles sections dans le menu principal

Modifications techniques :
- Ajout de nouveaux modèles en base de données
- Extension des helpers database
- Mise à jour des dépendances (requirements.txt)
- Amélioration de la gestion des annonces Twitch
- Refactorisation du code pour meilleure maintenabilité

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 22:37:22 +01:00
Mow910 d941fdcf9c Annonce twitch 2026-02-01 13:38:48 +01:00
Mow910 48531690fd Accueil restructuré (zones Discord/Twitch, stats, indicateur connexion) + Top 3 sanctions/modérateurs + BOT_STATUS dans config Flask 2026-02-01 12:55:38 +01:00
Mow910 9bbfa1fade Merge pull request #42 from skylanix/front-tailwind
Supprime les fichiers CSS obsolètes et met à jour les templates HTML …
2026-02-01 08:59:03 +01:00
Mow910 920ddfa172 Supprime les fichiers CSS obsolètes et met à jour les templates HTML pour une interface utilisateur améliorée. Les modifications incluent la refonte des pages de configuration, d'alerte live, de modération, et d'aide Twitch, avec une mise en page responsive et des styles modernisés. Les anciennes feuilles de style MVP.css et style.css ont été supprimées au profit d'une intégration de Tailwind CSS. 2026-01-25 19:07:13 +01:00
Mow910 a8d2a0e063 Ajout de nouvelles colonnes pour la personnalisation des notifications YouTube dans la table youtube_notification, y compris le titre, la description, la couleur, le pied de page, le nom et l'icône de l'auteur, ainsi que des options pour afficher la miniature et l'image. Mise à jour de l'interface web pour permettre la configuration de ces options et ajout d'une prévisualisation de l'embed Discord. 2026-01-25 17:45:59 +01:00
Mow910 f2cd19a053 Ajout d'un système de notifications YouTube avec une nouvelle table youtube_notification dans la base de données, intégration de la vérification des vidéos YouTube, et création d'une interface web pour gérer les notifications. Le bot Discord enverra des alertes pour les nouvelles vidéos détectées. 2026-01-25 17:28:38 +01:00
Mow910 4973144e54 Merge pull request #40 from skylanix/welcome-warn
Ajout d'une réaction d'avertissement pour les nouveaux membres ayant …
2025-12-16 15:37:56 +01:00
Mow 9afd3b2588 Ajout d'une réaction d'avertissement pour les nouveaux membres ayant moins de 7 jours de compte lors de l'envoi du message de bienvenue. 2025-12-03 20:40:39 +01:00
67 changed files with 13850 additions and 1788 deletions
+238 -5
View File
@@ -33,7 +33,13 @@ def _set_sqlite_pragma(dbapi_connection, connection_record):
except Exception: except Exception:
pass pass
def _tableExists(table_name: str, cursor: Cursor) -> bool:
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table_name,))
return cursor.fetchone() is not None
def _tableHaveColumn(table_name:str, column_name:str, cursor:Cursor) -> bool: def _tableHaveColumn(table_name:str, column_name:str, cursor:Cursor) -> bool:
if not _tableExists(table_name, cursor):
return False
cursor.execute(f'PRAGMA table_info({table_name})') cursor.execute(f'PRAGMA table_info({table_name})')
columns = cursor.fetchall() columns = cursor.fetchall()
return any(col[1] == column_name for col in columns) return any(col[1] == column_name for col in columns)
@@ -53,9 +59,9 @@ def _doPreImportMigration(cursor:Cursor):
_renameTable('game_bundle', 'game_bundle_old', cursor) _renameTable('game_bundle', 'game_bundle_old', cursor)
def _doPostImportMigration(cursor:Cursor): def _doPostImportMigration(cursor:Cursor):
if _tableEmpty('game_bundle', cursor) : if _tableEmpty('game_bundle', cursor) and _tableExists('game_bundle_old', cursor):
logging.info("remplir game_bundle avec game_bundle_old") logging.info("remplir game_bundle avec game_bundle_old")
bundles = cursor.execute(f'SELECT * FROM game_bundle_old').fetchall() bundles = cursor.execute('SELECT * FROM game_bundle_old').fetchall()
for bundle in bundles : for bundle in bundles :
name = bundle[1] name = bundle[1]
json_data = json.loads(bundle[2]) json_data = json.loads(bundle[2])
@@ -65,18 +71,245 @@ def _doPostImportMigration(cursor:Cursor):
logging.info("suppression de la table temporaire game_bundle_old") logging.info("suppression de la table temporaire game_bundle_old")
_dropTable('game_bundle_old', cursor) _dropTable('game_bundle_old', cursor)
if _tableExists('youtube_notification', cursor):
embed_columns = [
('embed_title', 'VARCHAR(256)'),
('embed_description', 'VARCHAR(2000)'),
('embed_color', 'VARCHAR(8) DEFAULT "FF0000"'),
('embed_footer', 'VARCHAR(2048)'),
('embed_author_name', 'VARCHAR(256)'),
('embed_author_icon', 'VARCHAR(512)'),
('embed_thumbnail', 'BOOLEAN DEFAULT 1'),
('embed_image', 'BOOLEAN DEFAULT 1'),
]
for col_name, col_type in embed_columns:
if not _tableHaveColumn('youtube_notification', col_name, cursor):
try:
cursor.execute(f'ALTER TABLE youtube_notification ADD COLUMN {col_name} {col_type}')
logging.info(f"Colonne {col_name} ajoutée à youtube_notification")
except Exception as e:
logging.warning(f"Colonne youtube_notification.{col_name}: {e}")
if _tableExists('commande', cursor) and not _tableHaveColumn('commande', 'twitch_permission', cursor):
try:
cursor.execute("ALTER TABLE commande ADD COLUMN twitch_permission VARCHAR(16) DEFAULT 'viewer'")
logging.info("Colonne twitch_permission ajoutée à commande")
except Exception as e:
logging.warning(f"Colonne commande.twitch_permission: {e}")
def _doAddColumnMigrations(cursor: Cursor):
"""Migrations d'ajout de colonnes. Exécutées à part pour ne pas dépendre du script principal."""
if _tableExists('commande', cursor) and not _tableHaveColumn('commande', 'twitch_permission', cursor):
try:
cursor.execute("ALTER TABLE commande ADD COLUMN twitch_permission VARCHAR(16) DEFAULT 'viewer'")
logging.info("Colonne twitch_permission ajoutée à commande")
except Exception as e:
logging.warning(f"Colonne commande.twitch_permission: {e}")
if _tableExists('live_alert', cursor) and not _tableHaveColumn('live_alert', 'watch_activity', cursor):
try:
cursor.execute("ALTER TABLE live_alert ADD COLUMN watch_activity BOOLEAN NOT NULL DEFAULT 0")
logging.info("Colonne watch_activity ajoutée à live_alert")
except Exception as e:
logging.warning(f"Colonne live_alert.watch_activity: {e}")
# Colonnes embed pour live_alert (message par défaut en embed)
if _tableExists('live_alert', cursor):
live_alert_embed_columns = [
('embed_title', 'VARCHAR(256)'),
('embed_description', 'VARCHAR(2000)'),
('embed_color', 'VARCHAR(8) DEFAULT "9146FF"'),
('embed_footer', 'VARCHAR(2048)'),
('embed_author_name', 'VARCHAR(256)'),
('embed_author_icon', 'VARCHAR(512)'),
('embed_thumbnail', 'BOOLEAN DEFAULT 1'),
('embed_image', 'BOOLEAN DEFAULT 1'),
]
for col_name, col_type in live_alert_embed_columns:
if not _tableHaveColumn('live_alert', col_name, cursor):
try:
cursor.execute(f'ALTER TABLE live_alert ADD COLUMN {col_name} {col_type}')
logging.info(f"Colonne {col_name} ajoutée à live_alert")
except Exception as e:
logging.warning(f"Colonne live_alert.{col_name}: {e}")
# Table twitch_event_notification + seed des 4 types
if not _tableExists('twitch_event_notification', cursor):
try:
cursor.execute("""
CREATE TABLE twitch_event_notification (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type VARCHAR(32) UNIQUE NOT NULL,
enable BOOLEAN NOT NULL DEFAULT 1,
notify_twitch_chat BOOLEAN NOT NULL DEFAULT 1,
notify_discord BOOLEAN NOT NULL DEFAULT 0,
discord_channel_id INTEGER NULL,
message_twitch VARCHAR(500) NOT NULL DEFAULT '',
message_discord VARCHAR(2000) NULL,
embed_color VARCHAR(8) DEFAULT '9146FF',
embed_title VARCHAR(256) NULL,
embed_description VARCHAR(2000) NULL,
embed_thumbnail BOOLEAN NOT NULL DEFAULT 1,
last_clip_id VARCHAR(128) NULL
)
""")
logging.info("Table twitch_event_notification créée")
except Exception as e:
logging.warning(f"Table twitch_event_notification: {e}")
if _tableExists('twitch_event_notification', cursor) and _tableEmpty('twitch_event_notification', cursor):
for ev in ('sub', 'follow', 'raid', 'clip'):
try:
cursor.execute(
"INSERT INTO twitch_event_notification (event_type, message_twitch) VALUES (?, ?)",
(ev, 'Merci {user} !' if ev != 'raid' else 'Bienvenue aux viewers de {from_broadcaster_name} !'),
)
except Exception as e:
logging.warning(f"Seed twitch_event_notification {ev}: {e}")
# Colonnes supplémentaires pour patreon_post (historique + statut notification)
if _tableExists('patreon_post', cursor):
patreon_columns = [
('title', 'VARCHAR(512)'),
('link', 'VARCHAR(1024)'),
('description', 'TEXT'),
('published_at', 'VARCHAR(64)'),
('notified', 'BOOLEAN NOT NULL DEFAULT 0'),
]
for col_name, col_type in patreon_columns:
if not _tableHaveColumn('patreon_post', col_name, cursor):
try:
cursor.execute(f'ALTER TABLE patreon_post ADD COLUMN {col_name} {col_type}')
logging.info(f"Colonne {col_name} ajoutée à patreon_post")
except Exception as e:
logging.warning(f"Colonne patreon_post.{col_name}: {e}")
# Table webapp_user (auth)
if not _tableExists('webapp_user', cursor):
try:
cursor.execute("""
CREATE TABLE webapp_user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username VARCHAR(64) UNIQUE NOT NULL,
email VARCHAR(256) UNIQUE NOT NULL,
password_hash VARCHAR(256) NOT NULL,
role VARCHAR(64) NOT NULL DEFAULT 'viewer_twitch',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""")
logging.info("Table webapp_user créée")
except Exception as e:
logging.warning(f"Table webapp_user: {e}")
# Tables webapp_role et webapp_page_permission
if not _tableExists('webapp_role', cursor):
try:
cursor.execute("""
CREATE TABLE webapp_role (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(64) UNIQUE NOT NULL,
level INTEGER NOT NULL DEFAULT 0
)
""")
logging.info("Table webapp_role créée")
except Exception as e:
logging.warning(f"Table webapp_role: {e}")
if not _tableExists('webapp_page_permission', cursor):
try:
cursor.execute("""
CREATE TABLE webapp_page_permission (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_key VARCHAR(64) UNIQUE NOT NULL,
min_level INTEGER NOT NULL DEFAULT 0,
write_level INTEGER NULL
)
""")
logging.info("Table webapp_page_permission créée")
except Exception as e:
logging.warning(f"Table webapp_page_permission: {e}")
def _doSeedAuth(cursor: Cursor):
"""Seed rôles par défaut et permissions des pages si vides."""
from database.models import ROLE_ORDER
if not _tableExists('webapp_role', cursor):
return
if cursor.execute("SELECT COUNT(*) FROM webapp_role").fetchone()[0] > 0:
return
default_roles = [
("viewer_twitch", 0),
("utilisateur_discord", 1),
("moderateur_discord", 2),
("expert_discord", 3),
("moderateur_twitch", 4),
("super_administrateur", 5),
]
for name, level in default_roles:
try:
cursor.execute("INSERT INTO webapp_role (name, level) VALUES (?, ?)", (name, level))
except Exception as e:
logging.warning(f"Seed role {name}: {e}")
logging.info("Rôles par défaut insérés")
if not _tableExists('webapp_page_permission', cursor):
return
if cursor.execute("SELECT COUNT(*) FROM webapp_page_permission").fetchone()[0] > 0:
return
# page_key, min_level, write_level (NULL = même que min_level)
default_pages = [
("index", 0, None),
("configurations", 5, 5),
("commandes", 1, 2),
("humeurs", 1, 2),
("live_alert", 0, 4),
("announcements", 0, 4),
("twitch_moderation", 0, 4),
("link_filter", 0, 4),
("twitch_events", 0, 4),
("youtube", 1, 2),
("protondb", 1, 2),
("freeloot", 1, 2),
("patreon", 1, 2),
("moderation", 1, 2),
("users", 5, 5),
("settings", 5, 5),
]
for page_key, min_level, wl in default_pages:
try:
cursor.execute(
"INSERT INTO webapp_page_permission (page_key, min_level, write_level) VALUES (?, ?, ?)",
(page_key, min_level, wl),
)
except Exception as e:
logging.warning(f"Seed page {page_key}: {e}")
logging.info("Permissions pages par défaut insérées")
# Inscriptions activées par défaut
if _tableExists("configuration", cursor):
try:
cursor.execute(
"INSERT OR IGNORE INTO configuration (key, value) VALUES ('registration_enabled', 'true')"
)
except Exception as e:
logging.warning(f"Config registration_enabled: {e}")
with webapp.app_context(): with webapp.app_context():
with open('database/schema.sql', 'r') as f: with open('database/schema.sql', 'r') as f:
sql = f.read() sql = f.read()
connection : Connection = db.session.connection().connection connection: Connection = db.session.connection().connection
try: try:
cursor : Cursor = connection.cursor() cursor = connection.cursor()
_doPreImportMigration(cursor) _doPreImportMigration(cursor)
cursor.executescript(sql) cursor.executescript(sql)
_doPostImportMigration(cursor) _doPostImportMigration(cursor)
connection.commit() connection.commit()
cursor.close()
except Exception as e: except Exception as e:
logging.error(f"lors de l'import de la bdd : {e}") logging.error(f"lors de l'import de la bdd : {e}")
finally: finally:
try:
cursor = connection.cursor()
_doAddColumnMigrations(cursor)
_doSeedAuth(cursor)
connection.commit()
except Exception as e:
logging.warning(f"migrations colonnes : {e}")
connection.close() connection.close()
+11
View File
@@ -0,0 +1,11 @@
-- Migration: Ajout de la table twitch_banned_word
-- Date: 2026-02-10
-- Description: Permet de gérer les mots interdits dans le chat Twitch
CREATE TABLE IF NOT EXISTS `twitch_banned_word` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`word` VARCHAR(256) UNIQUE NOT NULL,
`enabled` BOOLEAN NOT NULL DEFAULT TRUE,
`timeout_duration` INTEGER NOT NULL DEFAULT 60,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
+217 -6
View File
@@ -1,8 +1,66 @@
from datetime import datetime
from database import db from database import db
from flask_login import UserMixin
# Rôles par défaut (niveau 0 à 5) — seed en base via migration
ROLE_ORDER = [
"viewer_twitch",
"utilisateur_discord",
"moderateur_discord",
"expert_discord",
"moderateur_twitch",
"super_administrateur",
]
def role_level(role_name: str) -> int:
"""Retourne le niveau du rôle depuis la table webapp_role (-1 si inconnu)."""
if not role_name:
return -1
r = WebappRole.query.filter_by(name=role_name).first()
return r.level if r else -1
class WebappRole(db.Model):
__tablename__ = "webapp_role"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), unique=True, nullable=False)
level = db.Column(db.Integer, nullable=False, default=0)
description = db.Column(db.String(256), nullable=True)
color = db.Column(db.String(7), nullable=True, default="#6B7280") # Couleur hexadécimale pour l'affichage
icon = db.Column(db.String(32), nullable=True) # Nom d'icône (ex: 'user', 'shield', 'crown')
class PagePermission(db.Model):
__tablename__ = "webapp_page_permission"
id = db.Column(db.Integer, primary_key=True)
page_key = db.Column(db.String(64), unique=True, nullable=False)
min_level = db.Column(db.Integer, nullable=False, default=0)
write_level = db.Column(db.Integer, nullable=True)
category = db.Column(db.String(32), nullable=True, default="general") # Catégorie: 'general', 'moderation', 'content', 'config'
description = db.Column(db.String(256), nullable=True) # Description de la page
class WebappUser(db.Model, UserMixin):
__tablename__ = "webapp_user"
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, nullable=False)
email = db.Column(db.String(256), unique=True, nullable=False)
password_hash = db.Column(db.String(256), nullable=False)
role = db.Column(db.String(64), nullable=False, default="viewer_twitch")
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
def get_level(self) -> int:
return role_level(self.role)
def has_role_at_least(self, min_role: str) -> bool:
return self.get_level() >= role_level(min_role)
def has_level_at_least(self, level: int) -> bool:
return self.get_level() >= level
def has_any_role(self, roles: list) -> bool:
return self.role in roles
class Configuration(db.Model): class Configuration(db.Model):
key = db.Column(db.String(32), primary_key=True) key = db.Column(db.String(32), primary_key=True)
value = db.Column(db.String(512)) value = db.Column(db.Text)
class Humeur(db.Model): class Humeur(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
@@ -25,20 +83,36 @@ class LiveAlert(db.Model):
online = db.Column(db.Boolean, default=False) online = db.Column(db.Boolean, default=False)
login = db.Column(db.String(128)) login = db.Column(db.String(128))
notify_channel = db.Column(db.Integer) notify_channel = db.Column(db.Integer)
message = db.Column(db.String(2000)) message = db.Column(db.String(2000)) # message optionnel avant l'embed
watch_activity = db.Column(db.Boolean, default=False)
# Personnalisation de l'embed Discord (comme YouTube)
embed_title = db.Column(db.String(256))
embed_description = db.Column(db.String(2000))
embed_color = db.Column(db.String(8), default='9146FF') # violet Twitch
embed_footer = db.Column(db.String(2048))
embed_author_name = db.Column(db.String(256))
embed_author_icon = db.Column(db.String(512))
embed_thumbnail = db.Column(db.Boolean, default=True)
embed_image = db.Column(db.Boolean, default=True)
class Message(db.Model): class TwitchAnnouncement(db.Model):
__tablename__ = 'twitch_announcement'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
enable = db.Column(db.Boolean, default=False) enable = db.Column(db.Boolean, default=True)
text = db.Column(db.String(256)) name = db.Column(db.String(64))
periodicity = db.Column(db.Integer) text = db.Column(db.String(500))
periodicity = db.Column(db.Integer, default=10)
min_chat_messages = db.Column(db.Integer, default=0)
last_sent = db.Column(db.DateTime, nullable=True)
# Niveaux de permission Twitch pour les commandes personnalisées : viewer, sub, vip, moderator
class Commande(db.Model): class Commande(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
discord_enable = db.Column(db.Boolean, default=True) discord_enable = db.Column(db.Boolean, default=True)
twitch_enable = db.Column(db.Boolean, default=True) twitch_enable = db.Column(db.Boolean, default=True)
trigger = db.Column(db.String(32), unique=True) trigger = db.Column(db.String(32), unique=True)
response = db.Column(db.String(2000)) response = db.Column(db.String(2000))
twitch_permission = db.Column(db.String(16), default='viewer') # viewer | sub | vip | moderator
class ModerationEvent(db.Model): class ModerationEvent(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
@@ -51,6 +125,46 @@ class ModerationEvent(db.Model):
staff_name = db.Column(db.String(256)) staff_name = db.Column(db.String(256))
duration = db.Column(db.Integer) duration = db.Column(db.Integer)
class TwitchModerationLog(db.Model):
__tablename__ = 'twitch_moderation_log'
id = db.Column(db.Integer, primary_key=True)
action = db.Column(db.String(32))
moderator = db.Column(db.String(256))
target = db.Column(db.String(256))
details = db.Column(db.String(512))
created_at = db.Column(db.DateTime)
class TwitchLinkFilter(db.Model):
__tablename__ = 'twitch_link_filter'
id = db.Column(db.Integer, primary_key=True)
enabled = db.Column(db.Boolean, default=False)
allow_subscribers = db.Column(db.Boolean, default=True)
allow_vips = db.Column(db.Boolean, default=True)
allow_moderators = db.Column(db.Boolean, default=True)
timeout_duration = db.Column(db.Integer, default=60)
warning_message = db.Column(db.String(500), default="Les liens ne sont pas autorises dans le chat.")
class TwitchAllowedDomain(db.Model):
__tablename__ = 'twitch_allowed_domain'
id = db.Column(db.Integer, primary_key=True)
domain = db.Column(db.String(256), unique=True)
class TwitchPermit(db.Model):
__tablename__ = 'twitch_permit'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(256))
expires_at = db.Column(db.DateTime)
class TwitchAllowedUser(db.Model):
__tablename__ = 'twitch_allowed_user'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(256), unique=True)
class AntiCheatCache(db.Model): class AntiCheatCache(db.Model):
__tablename__ = 'anticheat_cache' __tablename__ = 'anticheat_cache'
steam_id = db.Column(db.String(32), primary_key=True) steam_id = db.Column(db.String(32), primary_key=True)
@@ -61,3 +175,100 @@ class AntiCheatCache(db.Model):
notes = db.Column(db.String(1024)) notes = db.Column(db.String(1024))
updated_at = db.Column(db.DateTime) updated_at = db.Column(db.DateTime)
class YouTubeNotification(db.Model):
__tablename__ = 'youtube_notification'
id = db.Column(db.Integer, primary_key=True)
enable = db.Column(db.Boolean, default=True)
channel_id = db.Column(db.String(128))
notify_channel = db.Column(db.Integer)
message = db.Column(db.String(2000))
video_type = db.Column(db.String(16), default='all')
last_video_id = db.Column(db.String(128))
embed_title = db.Column(db.String(256))
embed_description = db.Column(db.String(2000))
embed_color = db.Column(db.String(8), default='FF0000')
embed_footer = db.Column(db.String(2048))
embed_author_name = db.Column(db.String(256))
embed_author_icon = db.Column(db.String(512))
embed_thumbnail = db.Column(db.Boolean, default=True)
embed_image = db.Column(db.Boolean, default=True)
class YouTubeVideoHistory(db.Model):
__tablename__ = 'youtube_video_history'
id = db.Column(db.Integer, primary_key=True)
notification_id = db.Column(db.Integer, db.ForeignKey('youtube_notification.id'), nullable=False)
video_id = db.Column(db.String(128), nullable=False)
title = db.Column(db.String(512))
url = db.Column(db.String(512))
channel_name = db.Column(db.String(256))
thumbnail = db.Column(db.String(512))
published_at = db.Column(db.String(64))
is_short = db.Column(db.Boolean, default=False)
notified = db.Column(db.Boolean, default=False)
detected_at = db.Column(db.DateTime, default=datetime.utcnow)
class AutoRoom(db.Model):
"""État persistant des salons vocaux temporaires."""
__tablename__ = 'auto_room'
guild_id = db.Column(db.String(32), primary_key=True)
voice_channel_id = db.Column(db.String(32), primary_key=True)
owner_id = db.Column(db.String(32), nullable=False)
control_message_id = db.Column(db.String(32))
access_mode = db.Column(db.String(16), nullable=False, default='open')
whitelist = db.Column(db.Text, nullable=False, default='[]')
blacklist = db.Column(db.Text, nullable=False, default='[]')
managed_member_ids = db.Column(db.Text, nullable=False, default='[]')
class FreeLootEntry(db.Model):
__tablename__ = 'freeloot_entry'
entry_id = db.Column(db.String(256), primary_key=True)
class TwitchEventNotification(db.Model):
"""Configuration des notifications par type d'événement (sub, follow, raid, clip)."""
__tablename__ = 'twitch_event_notification'
id = db.Column(db.Integer, primary_key=True)
event_type = db.Column(db.String(32), unique=True, nullable=False) # sub, follow, raid, clip
enable = db.Column(db.Boolean, default=True)
notify_twitch_chat = db.Column(db.Boolean, default=True)
notify_discord = db.Column(db.Boolean, default=False)
discord_channel_id = db.Column(db.Integer, nullable=True)
message_twitch = db.Column(db.String(500), default='')
message_discord = db.Column(db.String(2000), nullable=True)
embed_color = db.Column(db.String(8), default='9146FF')
embed_title = db.Column(db.String(256), nullable=True)
embed_description = db.Column(db.String(2000), nullable=True)
embed_thumbnail = db.Column(db.Boolean, default=True)
last_clip_id = db.Column(db.String(128), nullable=True) # pour détecter les nouveaux clips
class TwitchBannedWord(db.Model):
"""Mots interdits dans le chat Twitch."""
__tablename__ = 'twitch_banned_word'
id = db.Column(db.Integer, primary_key=True)
word = db.Column(db.String(256), unique=True, nullable=False)
enabled = db.Column(db.Boolean, default=True)
timeout_duration = db.Column(db.Integer, default=60) # durée du timeout en secondes
created_at = db.Column(db.DateTime, default=datetime.utcnow)
class PatreonPost(db.Model):
__tablename__ = 'patreon_post'
guid = db.Column(db.String(512), primary_key=True)
title = db.Column(db.String(512))
link = db.Column(db.String(1024))
description = db.Column(db.Text)
published_at = db.Column(db.String(64))
notified = db.Column(db.Boolean, default=False)
class ModShoutboxMessage(db.Model):
__tablename__ = 'mod_shoutbox_message'
id = db.Column(db.Integer, primary_key=True)
author = db.Column(db.String(64), nullable=False)
message = db.Column(db.String(500), nullable=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
+172 -7
View File
@@ -1,7 +1,7 @@
CREATE TABLE IF NOT EXISTS `configuration` ( CREATE TABLE IF NOT EXISTS `configuration` (
`key` VARCHAR(32) PRIMARY KEY, `key` VARCHAR(32) PRIMARY KEY,
`value` VARCHAR(512) NOT NULL `value` TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS `game_alias` ( CREATE TABLE IF NOT EXISTS `game_alias` (
@@ -28,14 +28,26 @@ CREATE TABLE IF NOT EXISTS live_alert (
`online` BOOLEAN NOT NULL DEFAULT FALSE, `online` BOOLEAN NOT NULL DEFAULT FALSE,
`login` VARCHAR(128) UNIQUE NOT NULL, `login` VARCHAR(128) UNIQUE NOT NULL,
`notify_channel` INTEGER NOT NULL, `notify_channel` INTEGER NOT NULL,
`message` VARCHAR(2000) NOT NULL `message` VARCHAR(2000),
`watch_activity` BOOLEAN NOT NULL DEFAULT FALSE,
`embed_title` VARCHAR(256),
`embed_description` VARCHAR(2000),
`embed_color` VARCHAR(8) DEFAULT '9146FF',
`embed_footer` VARCHAR(2048),
`embed_author_name` VARCHAR(256),
`embed_author_icon` VARCHAR(512),
`embed_thumbnail` BOOLEAN DEFAULT TRUE,
`embed_image` BOOLEAN DEFAULT TRUE
); );
CREATE TABLE IF NOT EXISTS `message` ( CREATE TABLE IF NOT EXISTS `twitch_announcement` (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
`enable` BOOLEAN NOT NULL DEFAULT FALSE, `enable` BOOLEAN NOT NULL DEFAULT TRUE,
`text` VARCHAR(256) NULL, `name` VARCHAR(64) NOT NULL,
periodicity INTEGER NULL `text` VARCHAR(500) NOT NULL,
`periodicity` INTEGER NOT NULL DEFAULT 10,
`min_chat_messages` INTEGER NOT NULL DEFAULT 0,
`last_sent` DATETIME NULL
); );
CREATE TABLE IF NOT EXISTS `commande` ( CREATE TABLE IF NOT EXISTS `commande` (
@@ -43,7 +55,8 @@ CREATE TABLE IF NOT EXISTS `commande` (
`discord_enable` BOOLEAN NOT NULL DEFAULT TRUE, `discord_enable` BOOLEAN NOT NULL DEFAULT TRUE,
`twitch_enable` BOOLEAN NOT NULL DEFAULT TRUE, `twitch_enable` BOOLEAN NOT NULL DEFAULT TRUE,
`trigger` VARCHAR(16) UNIQUE NOT NULL, `trigger` VARCHAR(16) UNIQUE NOT NULL,
`response` VARCHAR(2000) NOT NULL `response` VARCHAR(2000) NOT NULL,
`twitch_permission` VARCHAR(16) DEFAULT 'viewer'
); );
CREATE TABLE IF NOT EXISTS `moderation_event` ( CREATE TABLE IF NOT EXISTS `moderation_event` (
@@ -58,6 +71,15 @@ CREATE TABLE IF NOT EXISTS `moderation_event` (
`duration` INTEGER NULL `duration` INTEGER NULL
); );
CREATE TABLE IF NOT EXISTS `twitch_moderation_log` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`action` VARCHAR(32) NOT NULL,
`moderator` VARCHAR(256) NOT NULL,
`target` VARCHAR(256),
`details` VARCHAR(512),
`created_at` DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS `anticheat_cache` ( CREATE TABLE IF NOT EXISTS `anticheat_cache` (
steam_id VARCHAR(32) PRIMARY KEY, steam_id VARCHAR(32) PRIMARY KEY,
game_name VARCHAR(256) NOT NULL, game_name VARCHAR(256) NOT NULL,
@@ -76,3 +98,146 @@ CREATE TABLE IF NOT EXISTS `member_invites` (
`inviter_name` VARCHAR(256), `inviter_name` VARCHAR(256),
`join_date` DATETIME NOT NULL `join_date` DATETIME NOT NULL
); );
CREATE TABLE IF NOT EXISTS `twitch_link_filter` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`enabled` BOOLEAN NOT NULL DEFAULT FALSE,
`allow_subscribers` BOOLEAN NOT NULL DEFAULT TRUE,
`allow_vips` BOOLEAN NOT NULL DEFAULT TRUE,
`allow_moderators` BOOLEAN NOT NULL DEFAULT TRUE,
`timeout_duration` INTEGER NOT NULL DEFAULT 60,
`warning_message` VARCHAR(500) NOT NULL DEFAULT 'Les liens ne sont pas autorises dans le chat.'
);
CREATE TABLE IF NOT EXISTS `twitch_allowed_domain` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`domain` VARCHAR(256) UNIQUE NOT NULL
);
CREATE TABLE IF NOT EXISTS `twitch_permit` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`username` VARCHAR(256) NOT NULL,
`expires_at` DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS `twitch_allowed_user` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`username` VARCHAR(256) UNIQUE NOT NULL
);
CREATE TABLE IF NOT EXISTS `twitch_banned_word` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`word` VARCHAR(256) UNIQUE NOT NULL,
`enabled` BOOLEAN NOT NULL DEFAULT TRUE,
`timeout_duration` INTEGER NOT NULL DEFAULT 60,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS `youtube_notification` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`enable` BOOLEAN NOT NULL DEFAULT TRUE,
`channel_id` VARCHAR(128) NOT NULL,
`notify_channel` INTEGER NOT NULL,
`message` VARCHAR(2000) NOT NULL,
`video_type` VARCHAR(16) NOT NULL DEFAULT 'all',
`last_video_id` VARCHAR(128),
`embed_title` VARCHAR(256),
`embed_description` VARCHAR(2000),
`embed_color` VARCHAR(8) NOT NULL DEFAULT 'FF0000',
`embed_footer` VARCHAR(2048),
`embed_author_name` VARCHAR(256),
`embed_author_icon` VARCHAR(512),
`embed_thumbnail` BOOLEAN NOT NULL DEFAULT TRUE,
`embed_image` BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE IF NOT EXISTS `webapp_user` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username VARCHAR(64) UNIQUE NOT NULL,
email VARCHAR(256) UNIQUE NOT NULL,
password_hash VARCHAR(256) NOT NULL,
role VARCHAR(64) NOT NULL DEFAULT 'viewer_twitch',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS `webapp_role` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(64) UNIQUE NOT NULL,
level INTEGER NOT NULL DEFAULT 0,
description VARCHAR(256) NULL,
color VARCHAR(7) NULL DEFAULT '#6B7280',
icon VARCHAR(32) NULL
);
CREATE TABLE IF NOT EXISTS `webapp_page_permission` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_key VARCHAR(64) UNIQUE NOT NULL,
min_level INTEGER NOT NULL DEFAULT 0,
write_level INTEGER NULL,
category VARCHAR(32) NULL DEFAULT 'general',
description VARCHAR(256) NULL
);
CREATE TABLE IF NOT EXISTS `youtube_video_history` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`notification_id` INTEGER NOT NULL,
`video_id` VARCHAR(128) NOT NULL,
`title` VARCHAR(512),
`url` VARCHAR(512),
`channel_name` VARCHAR(256),
`thumbnail` VARCHAR(512),
`published_at` VARCHAR(64),
`is_short` BOOLEAN NOT NULL DEFAULT FALSE,
`notified` BOOLEAN NOT NULL DEFAULT FALSE,
`detected_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`notification_id`) REFERENCES `youtube_notification`(`id`) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS `auto_room` (
`guild_id` VARCHAR(32) NOT NULL,
`voice_channel_id` VARCHAR(32) NOT NULL UNIQUE,
`owner_id` VARCHAR(32) NOT NULL,
`control_message_id` VARCHAR(32),
`access_mode` VARCHAR(16) NOT NULL DEFAULT 'open',
`whitelist` TEXT NOT NULL DEFAULT '[]',
`blacklist` TEXT NOT NULL DEFAULT '[]',
`managed_member_ids` TEXT NOT NULL DEFAULT '[]',
PRIMARY KEY (`guild_id`, `voice_channel_id`)
);
CREATE TABLE IF NOT EXISTS `freeloot_entry` (
entry_id VARCHAR(256) PRIMARY KEY
);
-- Notifications d'événements Twitch (sub, follow, raid, clip) : chat Twitch et/ou canal Discord
CREATE TABLE IF NOT EXISTS `twitch_event_notification` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type VARCHAR(32) UNIQUE NOT NULL,
`enable` BOOLEAN NOT NULL DEFAULT TRUE,
notify_twitch_chat BOOLEAN NOT NULL DEFAULT TRUE,
notify_discord BOOLEAN NOT NULL DEFAULT FALSE,
discord_channel_id INTEGER NULL,
message_twitch VARCHAR(500) NOT NULL DEFAULT '',
message_discord VARCHAR(2000) NULL,
embed_color VARCHAR(8) DEFAULT '9146FF',
embed_title VARCHAR(256) NULL,
embed_description VARCHAR(2000) NULL,
embed_thumbnail BOOLEAN NOT NULL DEFAULT TRUE,
last_clip_id VARCHAR(128) NULL
);
CREATE TABLE IF NOT EXISTS `patreon_post` (
guid VARCHAR(512) PRIMARY KEY,
title VARCHAR(512),
link VARCHAR(1024),
description TEXT,
published_at VARCHAR(64),
notified BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS `mod_shoutbox_message` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`author` VARCHAR(64) NOT NULL,
`message` VARCHAR(500) NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
+124 -115
View File
@@ -3,41 +3,122 @@ import discord
import logging import logging
import random import random
from webapp import webapp
from database import db from database import db
from database.helpers import ConfigurationHelper from database.helpers import ConfigurationHelper
from database.models import Configuration, Humeur, Commande from database.models import Configuration, Humeur, Commande
from discord import Message, TextChannel, Member from discord import Message, TextChannel, Member, VoiceChannel, app_commands
from discordbot.humblebundle import checkHumbleBundleAndNotify from discordbot.humblebundle import checkHumbleBundleAndNotify
from discordbot.freeloot import checkFreeLootAndNotify
from discordbot.moderation import ( from discordbot.moderation import (
handle_warning_command, handle_warning_command,
handle_remove_warning_command, handle_remove_warning_command,
handle_list_warnings_command, handle_list_warnings_command,
handle_ban_command,
handle_kick_command,
handle_unban_command, handle_unban_command,
handle_inspect_command, handle_inspect_command,
handle_ban_list_command, handle_ban_list_command,
handle_staff_help_command, handle_staff_help_command,
handle_timeout_command, handle_say_command,
handle_say_command handle_transfer_command,
transfer_message_context_menu,
moderation_slash_ban,
moderation_slash_kick,
moderation_slash_timeout,
moderation_ctx_ban_author,
moderation_ctx_kick_author,
moderation_ctx_timeout_author,
moderation_slash_warn,
moderation_slash_inspect,
moderation_ctx_warn_author,
moderation_slash_say,
) )
from discordbot.welcome import sendWelcomeMessage, sendLeaveMessage, updateInviteCache from discordbot.welcome import sendWelcomeMessage, sendLeaveMessage, updateInviteCache
from protondb import searhProtonDb from discordbot.rules_ack import assign_rules_arrival_on_join, register_persistent_rules_view
from discordbot.patreon import checkPatreonPosts
from discordbot.youtube import checkYouTubeVideos
from discordbot.auto_rooms import on_voice_state_update_auto_rooms, on_raw_reaction_add_auto_rooms, on_message_auto_rooms, cleanup_orphaned_auto_rooms, restore_auto_rooms
from discordbot.protondb_discord import protondb_slash_command, pdb_slash_command
class DiscordBot(discord.Client): class DiscordBot(discord.Client):
def __init__(self, *, intents: discord.Intents):
super().__init__(intents=intents)
self.tree = app_commands.CommandTree(self)
self.synced = False
self.background_tasks_started = False
async def setup_hook(self):
for cmd in (
transfer_message_context_menu,
moderation_slash_ban,
moderation_slash_kick,
moderation_slash_timeout,
moderation_ctx_ban_author,
moderation_ctx_kick_author,
moderation_ctx_timeout_author,
moderation_slash_warn,
moderation_slash_inspect,
moderation_ctx_warn_author,
moderation_slash_say,
protondb_slash_command,
pdb_slash_command,
):
self.tree.add_command(cmd)
logging.info("Commandes d'application (transfert, modération, ProtonDB) ajoutées au CommandTree")
register_persistent_rules_view(self)
logging.info("Vue persistante règlement (bouton) enregistrée")
async def on_ready(self): async def on_ready(self):
logging.info(f'Connecté en tant que {self.user} (ID: {self.user.id})') logging.info(f'Connecté en tant que {self.user} (ID: {self.user.id})')
webapp.config["BOT_STATUS"]["discord_connected"] = True
webapp.config["BOT_STATUS"]["discord_guild_count"] = len(self.guilds)
if not self.synced:
try:
logging.info("Synchronisation des commandes d'application en cours...")
for guild in self.guilds:
try:
synced = await self.tree.sync(guild=guild)
logging.info(f"{len(synced)} commande(s) synchronisée(s) pour le serveur '{guild.name}' (ID: {guild.id})")
except Exception as e:
logging.error(f"❌ Erreur lors de la synchronisation pour {guild.name}: {e}")
synced_global = await self.tree.sync()
logging.info(f"{len(synced_global)} commande(s) synchronisée(s) globalement")
self.synced = True
logging.info("🎉 Synchronisation complète terminée - Les commandes sont maintenant disponibles !")
except Exception as e:
logging.error(f"❌ Erreur lors de la synchronisation des commandes: {e}")
for c in self.get_all_channels() : for c in self.get_all_channels() :
logging.info(f'{c.id} {c.name}') logging.info(f'{c.id} {c.name}')
for guild in self.guilds: for guild in self.guilds:
await updateInviteCache(guild) await updateInviteCache(guild)
await restore_auto_rooms(self)
await cleanup_orphaned_auto_rooms(self)
# on_ready est rappelé après une reconnexion : ne pas démarrer plusieurs
# boucles de surveillance, qui peuvent envoyer des notifications en double.
if not self.background_tasks_started:
self.background_tasks_started = True
self.loop.create_task(self.updateStatus()) self.loop.create_task(self.updateStatus())
self.loop.create_task(self.updateHumbleBundle()) self.loop.create_task(self.updateHumbleBundle())
self.loop.create_task(self.updateYouTube())
self.loop.create_task(self.updateFreeLoot())
self.loop.create_task(self.updatePatreon())
async def on_disconnect(self):
webapp.config["BOT_STATUS"]["discord_connected"] = False
async def updateStatus(self): async def updateStatus(self):
while not self.is_closed(): while not self.is_closed():
bot_status = webapp.config.get("BOT_STATUS", {})
if bot_status.get("twitch_is_live") or bot_status.get("discord_streaming_activity"):
await asyncio.sleep(60)
continue
humeurs = Humeur.query.all() humeurs = Humeur.query.all()
if len(humeurs)>0 : if len(humeurs)>0 :
humeur = random.choice(humeurs) humeur = random.choice(humeurs)
@@ -51,6 +132,21 @@ class DiscordBot(discord.Client):
await checkHumbleBundleAndNotify(self) await checkHumbleBundleAndNotify(self)
await asyncio.sleep(30*60) await asyncio.sleep(30*60)
async def updateYouTube(self):
while not self.is_closed():
await checkYouTubeVideos()
await asyncio.sleep(5*60)
async def updateFreeLoot(self):
while not self.is_closed():
await checkFreeLootAndNotify(self)
await asyncio.sleep(30*60)
async def updatePatreon(self):
while not self.is_closed():
await checkPatreonPosts(self)
await asyncio.sleep(10*60)
def getAllTextChannel(self) -> list[TextChannel]: def getAllTextChannel(self) -> list[TextChannel]:
channels = [] channels = []
for channel in self.get_all_channels(): for channel in self.get_all_channels():
@@ -58,6 +154,13 @@ class DiscordBot(discord.Client):
channels.append(channel) channels.append(channel)
return channels return channels
def getAllVoiceChannels(self) -> list[VoiceChannel]:
channels = []
for channel in self.get_all_channels():
if isinstance(channel, VoiceChannel):
channels.append(channel)
return channels
def getAllRoles(self): def getAllRoles(self):
guilds_roles = [] guilds_roles = []
for guild in self.guilds: for guild in self.guilds:
@@ -92,6 +195,10 @@ bot = DiscordBot(intents=intents)
async def on_message(message: Message): async def on_message(message: Message):
if message.author == bot.user: if message.author == bot.user:
return return
# Gestion des messages dans les auto rooms (avant le check des commandes !)
await on_message_auto_rooms(bot, message)
if not message.content.startswith('!'): if not message.content.startswith('!'):
return return
command_name = message.content.split()[0] command_name = message.content.split()[0]
@@ -101,10 +208,6 @@ async def on_message(message: Message):
await handle_warning_command(message, bot) await handle_warning_command(message, bot)
return return
if command_name in ['!to', '!timeout']:
await handle_timeout_command(message, bot)
return
if command_name in ['!delaverto', '!removewarn', '!unwarn']: if command_name in ['!delaverto', '!removewarn', '!unwarn']:
await handle_remove_warning_command(message, bot) await handle_remove_warning_command(message, bot)
return return
@@ -114,10 +217,6 @@ async def on_message(message: Message):
return return
if ConfigurationHelper().getValue('moderation_ban_enable'): if ConfigurationHelper().getValue('moderation_ban_enable'):
if command_name == '!ban':
await handle_ban_command(message, bot)
return
if command_name == '!unban': if command_name == '!unban':
await handle_unban_command(message, bot) await handle_unban_command(message, bot)
return return
@@ -125,11 +224,6 @@ async def on_message(message: Message):
await handle_ban_list_command(message, bot) await handle_ban_list_command(message, bot)
return return
if ConfigurationHelper().getValue('moderation_kick_enable'):
if command_name == '!kick':
await handle_kick_command(message, bot)
return
if ConfigurationHelper().getValue('moderation_enable'): if ConfigurationHelper().getValue('moderation_enable'):
if command_name == '!inspect': if command_name == '!inspect':
await handle_inspect_command(message, bot) await handle_inspect_command(message, bot)
@@ -139,6 +233,10 @@ async def on_message(message: Message):
await handle_say_command(message, bot) await handle_say_command(message, bot)
return return
if command_name in ['!transfert', '!transfer', '!move']:
await handle_transfer_command(message, bot)
return
if command_name in ['!aide', '!help']: if command_name in ['!aide', '!help']:
await handle_staff_help_command(message, bot) await handle_staff_help_command(message, bot)
return return
@@ -151,105 +249,17 @@ async def on_message(message: Message):
except Exception as e: except Exception as e:
logging.error(f'Échec de l\'exécution de la commande Discord : {e}') logging.error(f'Échec de l\'exécution de la commande Discord : {e}')
if (ConfigurationHelper().getValue('proton_db_enable_enable') and (message.content.startswith('!protondb') or message.content.startswith('!pdb'))): @bot.event
if (message.content.find('<@')>0) : async def on_voice_state_update(member: Member, before, after):
mention = message.content[message.content.find('<@'):] await on_voice_state_update_auto_rooms(bot, member, before, after)
else :
mention = message.author.mention
name = message.content
if name.startswith('!protondb'):
name = name.replace('!protondb', '', 1)
elif name.startswith('!pdb'):
name = name.replace('!pdb', '', 1)
name = name.replace(f'{mention}', '').strip();
if not name or len(name) == 0: @bot.event
try: async def on_raw_reaction_add(payload: discord.RawReactionActionEvent):
await message.delete() await on_raw_reaction_add_auto_rooms(bot, payload)
delete_time = ConfigurationHelper().getIntValue('proton_db_delete_time') or 10
help_msg = await message.channel.send(
f"{mention} ⚠️ Utilisation: `!pdb nom du jeu` ou `!protondb nom du jeu`\n"
f"Exemple: `!pdb Elden Ring`",
suppress_embeds=True
)
await asyncio.sleep(delete_time)
await help_msg.delete()
except Exception as e:
logging.error(f"Échec de la gestion du message d'aide ProtonDB : {e}")
return
try:
searching_msg = await message.channel.send(f"🔍 Recherche en cours pour **{name}**...")
games = searhProtonDb(name)
await searching_msg.delete()
except:
games = searhProtonDb(name)
if (len(games)==0) :
msg = f'{mention} Je n\'ai pas trouvé de jeux correspondant à **{name}**. Es-tu sûr que le jeu est disponible sur Steam ?'
try:
await message.channel.send(msg, suppress_embeds=True)
except Exception as e:
logging.error(f"Échec de l'envoi du message ProtonDB : {e}")
return
total_games = len(games)
tier_colors = {'platinum': '🟣', 'gold': '🟡', 'silver': '', 'bronze': '🟤', 'borked': '🔴'}
content = ""
max_games = 15
for count, game in enumerate(games[:max_games]):
g_name = str(game.get('name'))
g_id = str(game.get('id'))
tier = str(game.get('tier') or 'N/A').lower()
tier_icon = tier_colors.get(tier, '')
new_entry = f"**[{g_name}](<https://www.protondb.com/app/{g_id}>)**\n{tier_icon} Classé **{tier.capitalize()}**"
ac_status = game.get('anticheat_status')
if ac_status:
status_lower = str(ac_status).lower()
ac_map = {
'supported': ('', 'Supporté'),
'running': ('⚠️', 'Fonctionne'),
'broken': ('', 'Cassé'),
'denied': ('🚫', 'Refusé'),
'planned': ('📅', 'Planifié')
}
ac_emoji, ac_label = ac_map.get(status_lower, ('', str(ac_status)))
acs = game.get('anticheats') or []
ac_list = ', '.join([str(ac) for ac in acs if ac])
new_entry += f" • [Anti-cheat {ac_emoji} {ac_label}"
if ac_list:
new_entry += f" ({ac_list})"
new_entry += f"](<https://areweanticheatyet.com/game/{g_id}>)"
new_entry += "\n\n"
# Vérifier la limite avant d'ajouter
if len(content) + len(new_entry) > 3900:
rest = len(games) - count
content += f"*... et {rest} autre{'s' if rest > 1 else ''} jeu{'x' if rest > 1 else ''}*"
break
content += new_entry
else:
rest = max(0, len(games) - max_games)
if rest > 0:
content += f"*... et {rest} autre{'s' if rest > 1 else ''} jeu{'x' if rest > 1 else ''}*"
embed = discord.Embed(
title=f"🎮 Résultats ProtonDB - **{total_games} jeu{'x' if total_games > 1 else ''} trouvé{'s' if total_games > 1 else ''}**",
description=content,
color=0x5865F2
)
try :
await message.channel.send(embed=embed)
except Exception as e:
logging.error(f"Échec de l'envoi de l'embed ProtonDB : {e}")
@bot.event @bot.event
async def on_member_join(member: Member): async def on_member_join(member: Member):
await assign_rules_arrival_on_join(bot, member)
await sendWelcomeMessage(bot, member) await sendWelcomeMessage(bot, member)
@bot.event @bot.event
@@ -263,4 +273,3 @@ async def on_invite_create(invite):
@bot.event @bot.event
async def on_invite_delete(invite): async def on_invite_delete(invite):
await updateInviteCache(invite.guild) await updateInviteCache(invite.guild)
+731
View File
@@ -0,0 +1,731 @@
# discordbot/auto_rooms.py — Auto rooms : message et réactions dans la partie texte du salon vocal (onglet Discussion)
import logging
import re
import json
from typing import Optional
import discord
from discord import Member, VoiceState
from database import db
from database.helpers import ConfigurationHelper
from database.models import AutoRoom
from webapp import webapp
# (guild_id, owner_id) -> room_data (voice_channel_id, control_message_id, whitelist, blacklist, access_mode)
_rooms: dict[tuple[int, int], dict] = {}
# message_id -> (guild_id, owner_id) pour retrouver la room depuis une réaction
_control_message_ids: dict[int, tuple[int, int]] = {}
# Emoji -> action
REACTIONS = [
("🔓", "open", "Ouvert"),
("🔒", "closed", "Fermé"),
("🔐", "private", "Privé"),
("", "whitelist", "Liste blanche"),
("🚫", "blacklist", "Liste noire"),
("🧹", "purge", "Purge"),
("👑", "transfer", "Propriété"),
("🎤", "speak", "Micro"),
("📹", "stream", "Vidéo"),
("📊", "soundboards", "Soundboards"),
("📝", "status", "Statut"),
]
def _status_display(access_mode: str) -> str:
"""Cadenas ouvert ou fermé selon si le salon est ouvert ou pas."""
if access_mode == "open":
return "🔓 Ouvert"
if access_mode == "closed":
return "🔒 Fermé"
if access_mode == "private":
return "🔐 Privé"
return "🔓 Ouvert"
def _status_emoji(access_mode: str) -> str:
"""Emoji cadenas seul pour le nom du channel."""
if access_mode == "private":
return "🔐"
return "🔓" if access_mode == "open" else "🔒"
def _build_control_embed(owner: Member, voice_channel: discord.VoiceChannel, access_mode: str, room: dict = None) -> discord.Embed:
"""Construit l'embed de config avec infos du salon."""
embed = discord.Embed(
title="⚙️ Configuration du salon",
description=(
"Voici l'espace de configuration de votre salon vocal temporaire. "
"Les différentes options disponibles vous permettent de personnaliser les permissions de votre salon selon vos préférences."
),
color=discord.Color.orange()
)
# Récupération des infos
whitelist = room.get("whitelist", set()) if room else set()
blacklist = room.get("blacklist", set()) if room else set()
whitelist_text = f"{len(whitelist)} membre(s)" if whitelist else "Aucun"
blacklist_text = f"{len(blacklist)} membre(s)" if blacklist else "Aucun"
# Section Propriétaire
embed.add_field(
name=f"👤 Propriétaire du salon : {owner.display_name}",
value="",
inline=False
)
# Section Modes d'accès
mode_open = "🔓 **Ouvert**\nLe salon sera ouvert à tous les membres, sauf ceux figurant sur la liste noire."
mode_closed = "🔒 **Fermé**\nLe salon sera visible de tous, mais seulement accessible à la liste blanche."
mode_private = "🔐 **Privé**\nLe salon ne sera visible et accessible qu'aux membres de la liste blanche."
embed.add_field(name=mode_open, value="", inline=True)
embed.add_field(name=mode_closed, value="", inline=True)
embed.add_field(name=mode_private, value="", inline=True)
# Section Listes
embed.add_field(
name="📝 **Liste blanche**",
value=f"Les membres présents dans cette liste pourront toujours rejoindre le salon.\n\n{whitelist_text}",
inline=True
)
embed.add_field(
name="🚫 **Liste noire**",
value=f"Les membres présents dans cette liste ne pourront jamais rejoindre le salon.\n\n{blacklist_text}",
inline=True
)
embed.add_field(name="\u200b", value="", inline=True) # Spacer
# Section Purge
embed.add_field(
name="🧹 **Purge**",
value="Déconnecter tous les membres du salon vocal à l'exception de ceux présents dans la liste blanche.",
inline=False
)
# Section Transfert
embed.add_field(
name="👑 **Transférer**",
value="Transférer la gestion du salon au membre de votre choix.",
inline=False
)
# Note importante
embed.add_field(
name="💡",
value="Les membres de la liste blanche ne sont pas impactés par les permissions refusées aux membres.",
inline=False
)
embed.set_footer(text="Réagissez avec les émojis ci-dessous pour configurer votre salon")
return embed
def _room_key(guild_id: int, owner_id: int) -> tuple[int, int]:
return (guild_id, owner_id)
def _get_room(guild_id: int, owner_id: int) -> Optional[dict]:
room = _rooms.get(_room_key(guild_id, owner_id))
if room:
return room
# Le cache peut être perdu après un redémarrage ou une reconnexion : la base
# reste la source de vérité pour éviter de créer une deuxième room au même membre.
with webapp.app_context():
record = AutoRoom.query.filter_by(guild_id=str(guild_id), owner_id=str(owner_id)).first()
if not record:
return None
try:
room = {
"guild_id": guild_id,
"voice_channel_id": int(record.voice_channel_id),
"control_message_id": int(record.control_message_id) if record.control_message_id else None,
"owner_id": owner_id,
"whitelist": set(json.loads(record.whitelist or "[]")),
"blacklist": set(json.loads(record.blacklist or "[]")),
"managed_member_ids": set(json.loads(record.managed_member_ids or "[]")),
"access_mode": record.access_mode or "open",
}
except (TypeError, ValueError):
return None
_rooms[_room_key(guild_id, owner_id)] = room
if room["control_message_id"]:
_control_message_ids[room["control_message_id"]] = (guild_id, owner_id)
return room
def _set_room(guild_id: int, owner_id: int, data: dict):
_rooms[_room_key(guild_id, owner_id)] = data
mid = data.get("control_message_id")
if mid:
_control_message_ids[mid] = (guild_id, owner_id)
_persist_room(guild_id, owner_id, data)
def _del_room(guild_id: int, owner_id: int):
data = _rooms.pop(_room_key(guild_id, owner_id), None)
if data and data.get("control_message_id"):
_control_message_ids.pop(data["control_message_id"], None)
if data:
with webapp.app_context():
AutoRoom.query.filter_by(guild_id=str(guild_id), voice_channel_id=str(data["voice_channel_id"])).delete()
db.session.commit()
def _persist_room(guild_id: int, owner_id: int, data: dict):
"""Sauvegarde l'état nécessaire à la reprise après redémarrage."""
with webapp.app_context():
record = AutoRoom.query.filter_by(
guild_id=str(guild_id), voice_channel_id=str(data["voice_channel_id"])
).first()
if not record:
record = AutoRoom(guild_id=str(guild_id), voice_channel_id=str(data["voice_channel_id"]))
db.session.add(record)
record.owner_id = str(owner_id)
record.control_message_id = str(data["control_message_id"]) if data.get("control_message_id") else None
record.access_mode = data.get("access_mode", "open")
record.whitelist = json.dumps(sorted(data.get("whitelist", set())))
record.blacklist = json.dumps(sorted(data.get("blacklist", set())))
record.managed_member_ids = json.dumps(sorted(data.get("managed_member_ids", set())))
db.session.commit()
def _auto_rooms_config() -> tuple[bool, int]:
with webapp.app_context():
config = ConfigurationHelper()
return bool(config.getValue("auto_rooms_enable")), config.getIntValue("auto_rooms_channel_id")
def _find_room_by_channel(guild_id: int, channel_id: int) -> Optional[tuple[int, dict]]:
for (gid, oid), data in _rooms.items():
if gid == guild_id and data.get("voice_channel_id") == channel_id:
return (oid, data)
with webapp.app_context():
record = AutoRoom.query.filter_by(guild_id=str(guild_id), voice_channel_id=str(channel_id)).first()
if record:
room = _get_room(guild_id, int(record.owner_id))
if room:
return (int(record.owner_id), room)
return None
def _find_room_by_message(message_id: int) -> Optional[tuple[int, int, dict]]:
key = _control_message_ids.get(message_id)
if not key:
return None
guild_id, owner_id = key
data = _get_room(guild_id, owner_id)
if not data:
_control_message_ids.pop(message_id, None)
return None
return (guild_id, owner_id, data)
async def _apply_access_mode(channel: discord.VoiceChannel, mode: str, whitelist: set, blacklist: set, room: dict):
guild = channel.guild
everyone = guild.default_role
overwrites = dict(channel.overwrites) # Récupérer les overwrites existants
# Préserver les permissions existantes pour everyone (stream, speak, soundboards, etc.)
existing_everyone_ow = overwrites.get(everyone, discord.PermissionOverwrite())
everyone_ow = discord.PermissionOverwrite()
# Copier les permissions importantes qui ne doivent pas être écrasées
everyone_ow.stream = existing_everyone_ow.stream
everyone_ow.speak = existing_everyone_ow.speak
everyone_ow.use_soundboard = existing_everyone_ow.use_soundboard
if mode == "open":
everyone_ow.connect = True
everyone_ow.view_channel = True
# Ne toucher qu'aux overwrites créés par l'auto room, jamais aux droits
# ajoutés manuellement par la modération.
for target in list(overwrites.keys()):
if target != everyone and isinstance(target, discord.Member):
if target.id in room.get("managed_member_ids", set()) and target.id not in blacklist:
overwrites.pop(target, None)
# Ajouter les overwrites pour la blacklist
for uid in blacklist:
m = guild.get_member(uid)
if m:
overwrites[m] = discord.PermissionOverwrite(connect=False, view_channel=True)
elif mode == "closed":
everyone_ow.connect = False
everyone_ow.view_channel = True
for target in list(overwrites.keys()):
if target != everyone and isinstance(target, discord.Member):
if target.id in room.get("managed_member_ids", set()) and target.id not in whitelist:
overwrites.pop(target, None)
# Ajouter les overwrites pour la whitelist
for uid in whitelist:
m = guild.get_member(uid)
if m:
overwrites[m] = discord.PermissionOverwrite(connect=True, view_channel=True)
elif mode == "private":
everyone_ow.connect = False
everyone_ow.view_channel = False
for target in list(overwrites.keys()):
if target != everyone and isinstance(target, discord.Member):
if target.id in room.get("managed_member_ids", set()) and target.id not in whitelist:
overwrites.pop(target, None)
# Ajouter les overwrites pour la whitelist
for uid in whitelist:
m = guild.get_member(uid)
if m:
overwrites[m] = discord.PermissionOverwrite(connect=True, view_channel=True)
overwrites[everyone] = everyone_ow
await channel.edit(overwrites=overwrites)
room["managed_member_ids"] = set(blacklist if mode == "open" else whitelist)
async def _handle_reaction_action(bot: discord.Client, guild_id: int, owner_id: int, action: str, channel):
"""channel = salon vocal (partie texte / onglet Discussion)."""
room = _get_room(guild_id, owner_id)
if not room:
await channel.send("Ce salon n'existe plus.")
return
voice_channel = bot.get_channel(room["voice_channel_id"])
if not voice_channel or not isinstance(voice_channel, discord.VoiceChannel):
await channel.send("Salon vocal introuvable.")
return
if action in ("open", "closed", "private"):
room["access_mode"] = action
await _apply_access_mode(voice_channel, action, room.get("whitelist", set()), room.get("blacklist", set()), room)
_persist_room(guild_id, owner_id, room)
# Mettre à jour le cadenas dans le nom du channel
try:
base_name = voice_channel.name.rstrip(" 🔓🔒🔐")
new_name = f"{base_name} {_status_emoji(action)}"
await voice_channel.edit(name=new_name)
except discord.HTTPException:
pass
await channel.send(f"Accès du salon défini sur **{_status_display(action)}**.")
# Mettre à jour l'embed
await _update_control_panel(bot, guild_id, owner_id, channel)
elif action == "whitelist":
whitelist = room.get("whitelist", set())
whitelist_text = ", ".join([f"<@{uid}>" for uid in whitelist]) if whitelist else "Aucun membre"
await channel.send(
f"**📝 Liste blanche actuelle :** {whitelist_text}\n\n"
f"Mentionnez un membre pour l'ajouter ou le retirer de la liste blanche."
)
room["awaiting_whitelist"] = True
elif action == "blacklist":
blacklist = room.get("blacklist", set())
blacklist_text = ", ".join([f"<@{uid}>" for uid in blacklist]) if blacklist else "Aucun membre"
await channel.send(
f"**🚫 Liste noire actuelle :** {blacklist_text}\n\n"
f"Mentionnez un membre pour l'ajouter ou le retirer de la liste noire."
)
room["awaiting_blacklist"] = True
elif action == "purge":
whitelist = room.get("whitelist", set())
kicked = 0
for member in list(voice_channel.members):
if member.id == owner_id or member.id in whitelist:
continue
try:
await member.move_to(None)
kicked += 1
except discord.HTTPException:
pass
await channel.send(f"🧹 Purge effectuée : {kicked} membre(s) déconnecté(s).")
elif action == "transfer":
await channel.send(
f"**👑 Transfert de propriété**\n\n"
f"Mentionnez le membre à qui vous souhaitez transférer la gestion du salon."
)
room["awaiting_transfer"] = True
elif action in ("speak", "stream", "soundboards"):
everyone = voice_channel.guild.default_role
overwrites = dict(voice_channel.overwrites)
ow = overwrites.get(everyone) or discord.PermissionOverwrite()
# BUG FIX : Par défaut, Discord autorise stream, speak et soundboards
# Si la permission n'est pas explicitement définie (None), on considère qu'elle est True
current = getattr(ow, action)
if current is None:
# Permission non définie = autorisée par défaut dans Discord
# On veut la désactiver lors du premier clic
setattr(ow, action, False)
new_value = False
else:
# Permission définie, on l'inverse
setattr(ow, action, not current)
new_value = not current
overwrites[everyone] = ow
await voice_channel.edit(overwrites=overwrites)
labels = {"speak": "Micro", "stream": "Vidéo/Partage d'écran", "soundboards": "Soundboards"}
label = labels.get(action, action.capitalize())
await channel.send(f"{label} : {'autorisé' if new_value else 'désactivé'} pour tous.")
elif action == "status":
current_status = voice_channel.status or "Aucun statut défini"
await channel.send(
f"**Statut actuel du salon :** {current_status}\n\n"
f"Pour modifier le statut du salon (le texte affiché en haut du salon vocal), "
f"répondez avec le nouveau statut (max 500 caractères).\n"
f"💡 Pour supprimer le statut, répondez avec `clear` ou `effacer`."
)
room["awaiting_status"] = True
async def _update_control_panel(bot: discord.Client, guild_id: int, owner_id: int, channel):
"""Met à jour le panneau de contrôle avec les nouvelles informations."""
room = _get_room(guild_id, owner_id)
if not room:
return
control_message_id = room.get("control_message_id")
if not control_message_id:
return
voice_channel = bot.get_channel(room["voice_channel_id"])
if not voice_channel or not isinstance(voice_channel, discord.VoiceChannel):
return
owner = voice_channel.guild.get_member(owner_id)
if not owner:
return
try:
msg = await channel.fetch_message(control_message_id)
embed = _build_control_embed(owner, voice_channel, room.get("access_mode", "open"), room)
await msg.edit(embed=embed)
except discord.HTTPException:
pass
async def send_control_panel(bot: discord.Client, guild_id: int, owner: Member, voice_channel: discord.VoiceChannel, room: dict) -> Optional[int]:
"""Envoie le message de config avec réactions dans la partie texte du salon vocal (onglet Discussion). Seul le proprio peut réagir. Retourne l'id du message."""
embed = _build_control_embed(owner, voice_channel, "open", room)
try:
# Message dans la partie texte du vocal (onglet Discussion à droite)
msg = await voice_channel.send(embed=embed)
for emoji, _action, _label in REACTIONS:
await msg.add_reaction(emoji)
return msg.id
except discord.HTTPException as e:
logging.error(f"Impossible d'envoyer le panneau Auto Room dans le vocal : {e}")
return None
_AUTO_ROOM_NAME_PATTERN = re.compile(r"^Salon de .+ [🔓🔒🔐]$")
async def cleanup_orphaned_auto_rooms(bot: discord.Client):
"""Supprime les auto rooms orphelines (vides) au démarrage du bot."""
enabled, trigger_channel_id = _auto_rooms_config()
if not enabled:
return
if not trigger_channel_id:
return
deleted = 0
# Les rooms persistées sont connues même si leur nom a été modifié manuellement.
for (guild_id, owner_id), room in list(_rooms.items()):
channel = bot.get_channel(room["voice_channel_id"])
if isinstance(channel, discord.VoiceChannel) and not channel.members:
try:
await channel.delete(reason="Nettoyage auto room vide au démarrage")
_del_room(guild_id, owner_id)
deleted += 1
except discord.HTTPException:
pass
for guild in bot.guilds:
trigger_channel = guild.get_channel(trigger_channel_id)
if not trigger_channel or not trigger_channel.category:
continue
category = trigger_channel.category
for channel in list(category.voice_channels):
if channel.id == trigger_channel_id:
continue
if not _AUTO_ROOM_NAME_PATTERN.match(channel.name):
continue
if len(channel.members) == 0:
try:
await channel.delete(reason="Nettoyage auto room orpheline au démarrage")
result = _find_room_by_channel(guild.id, channel.id)
if result:
_del_room(guild.id, result[0])
deleted += 1
except discord.HTTPException:
pass
if deleted > 0:
logging.info(f"Nettoyage auto rooms : {deleted} salon(s) orphelin(s) supprimé(s)")
async def restore_auto_rooms(bot: discord.Client):
"""Recharge les salons encore existants après un redémarrage du bot."""
with webapp.app_context():
records = AutoRoom.query.all()
for record in records:
guild = bot.get_guild(int(record.guild_id))
channel = guild.get_channel(int(record.voice_channel_id)) if guild else None
if not isinstance(channel, discord.VoiceChannel):
with webapp.app_context():
db.session.delete(db.session.merge(record))
db.session.commit()
continue
try:
whitelist = set(json.loads(record.whitelist or "[]"))
blacklist = set(json.loads(record.blacklist or "[]"))
managed_member_ids = set(json.loads(record.managed_member_ids or "[]"))
except (TypeError, ValueError):
whitelist, blacklist, managed_member_ids = set(), set(), set()
_set_room(int(record.guild_id), int(record.owner_id), {
"guild_id": int(record.guild_id),
"voice_channel_id": int(record.voice_channel_id),
"control_message_id": int(record.control_message_id) if record.control_message_id else None,
"owner_id": int(record.owner_id),
"whitelist": whitelist,
"blacklist": blacklist,
"managed_member_ids": managed_member_ids,
"access_mode": record.access_mode or "open",
})
async def on_voice_state_update_auto_rooms(bot: discord.Client, member: Member, before: VoiceState, after: VoiceState):
enabled, trigger_channel_id = _auto_rooms_config()
if not enabled:
return
if not trigger_channel_id:
return
guild = member.guild
if after.channel and after.channel.id == trigger_channel_id:
existing_room = _get_room(guild.id, member.id)
if existing_room:
old_channel = bot.get_channel(existing_room["voice_channel_id"])
if old_channel and isinstance(old_channel, discord.VoiceChannel):
# Ne jamais abandonner une room encore occupée : le propriétaire y retourne.
await member.move_to(old_channel)
return
_del_room(guild.id, member.id)
category = after.channel.category
channel_name = f"Salon de {member.display_name} {_status_emoji('open')}"
try:
new_channel = await guild.create_voice_channel(
name=channel_name,
category=category,
reason="Auto room"
)
await member.move_to(new_channel)
# Créer la room data d'abord
room_data = {
"guild_id": guild.id,
"voice_channel_id": new_channel.id,
"control_message_id": None, # Sera mis à jour après
"owner_id": member.id,
"whitelist": set(),
"blacklist": set(),
"managed_member_ids": set(),
"access_mode": "open",
}
control_message_id = await send_control_panel(bot, guild.id, member, new_channel, room_data)
if not control_message_id:
await new_channel.delete(reason="Panneau Auto Room impossible à créer")
await member.move_to(after.channel)
return
room_data["control_message_id"] = control_message_id
_set_room(guild.id, member.id, room_data)
logging.info(f"Auto room créé : {new_channel.name} pour {member.display_name}")
except discord.HTTPException as e:
logging.error(f"Erreur création auto room : {e}")
if before.channel and before.channel != after.channel and before.channel.id != trigger_channel_id:
result = _find_room_by_channel(guild.id, before.channel.id)
if result:
owner_id, room = result
remaining = [m for m in before.channel.members if m.id != member.id]
if member.id == owner_id and remaining:
new_owner = remaining[0]
_del_room(guild.id, owner_id)
room["owner_id"] = new_owner.id
_set_room(guild.id, new_owner.id, room)
await before.channel.send(f"👑 {new_owner.mention} est maintenant propriétaire du salon.")
await _update_control_panel(bot, guild.id, new_owner.id, before.channel)
elif len(remaining) == 0:
_del_room(guild.id, owner_id)
try:
await before.channel.delete(reason="Auto room vide")
except discord.HTTPException:
pass
async def on_message_auto_rooms(bot: discord.Client, message: discord.Message):
"""Gère les messages dans les salons vocaux pour les actions (statut, liste blanche/noire, etc.)."""
if message.author.bot:
return
enabled, _ = _auto_rooms_config()
if not enabled:
return
# Vérifier si c'est dans un salon vocal (partie texte)
if not isinstance(message.channel, discord.VoiceChannel):
return
# Trouver si c'est une auto room
result = _find_room_by_channel(message.guild.id, message.channel.id)
if not result:
return
owner_id, room = result
# Seul le propriétaire peut interagir
if message.author.id != owner_id:
return
voice_channel = message.channel
# Gestion du statut de salon
if room.get("awaiting_status"):
room["awaiting_status"] = False
new_status = message.content.strip()
try:
if new_status.lower() in ("clear", "effacer", "supprimer", "delete"):
await voice_channel.edit(status=None)
await message.channel.send("✅ Le statut du salon a été supprimé.")
elif len(new_status) > 500:
await message.channel.send("❌ Le statut ne peut pas dépasser 500 caractères.")
room["awaiting_status"] = True # Réessayer
else:
await voice_channel.edit(status=new_status)
await message.channel.send(f"✅ Le statut du salon a été mis à jour : **{new_status}**")
except discord.HTTPException as e:
await message.channel.send(f"❌ Erreur lors de la modification du statut : {e}")
return
# Gestion de la liste blanche (si en attente)
if room.get("awaiting_whitelist"):
room["awaiting_whitelist"] = False
if message.mentions:
target = message.mentions[0]
whitelist = room.get("whitelist", set())
if target.id in whitelist:
whitelist.remove(target.id)
await message.channel.send(f"{target.mention} a été retiré de la liste blanche.")
else:
whitelist.add(target.id)
await message.channel.send(f"{target.mention} a été ajouté à la liste blanche.")
room["whitelist"] = whitelist
blacklist = room.get("blacklist", set())
blacklist.discard(target.id)
room["blacklist"] = blacklist
await _apply_access_mode(voice_channel, room.get("access_mode", "open"), whitelist, blacklist, room)
_persist_room(message.guild.id, owner_id, room)
await _update_control_panel(bot, message.guild.id, owner_id, message.channel)
return
# Gestion de la liste noire (si en attente)
if room.get("awaiting_blacklist"):
room["awaiting_blacklist"] = False
if message.mentions:
target = message.mentions[0]
blacklist = room.get("blacklist", set())
if target.id in blacklist:
blacklist.remove(target.id)
await message.channel.send(f"{target.mention} a été retiré de la liste noire.")
else:
blacklist.add(target.id)
await message.channel.send(f"{target.mention} a été ajouté à la liste noire.")
room["blacklist"] = blacklist
whitelist = room.get("whitelist", set())
whitelist.discard(target.id)
room["whitelist"] = whitelist
await _apply_access_mode(voice_channel, room.get("access_mode", "open"), whitelist, blacklist, room)
_persist_room(message.guild.id, owner_id, room)
await _update_control_panel(bot, message.guild.id, owner_id, message.channel)
return
# Gestion du transfert de propriété (si en attente)
if room.get("awaiting_transfer"):
room["awaiting_transfer"] = False
if message.mentions:
new_owner = message.mentions[0]
if new_owner.id == owner_id:
await message.channel.send("❌ Vous êtes déjà le propriétaire du salon.")
return
# Transférer la propriété
old_owner_id = owner_id
_del_room(message.guild.id, old_owner_id)
room["owner_id"] = new_owner.id
_set_room(message.guild.id, new_owner.id, room)
# Renommer le salon
try:
base_name = f"Salon de {new_owner.display_name}"
new_name = f"{base_name} {_status_emoji(room.get('access_mode', 'open'))}"
await voice_channel.edit(name=new_name)
except discord.HTTPException:
pass
await message.channel.send(f"✅ La propriété du salon a été transférée à {new_owner.mention}.")
await _update_control_panel(bot, message.guild.id, new_owner.id, message.channel)
return
async def on_raw_reaction_add_auto_rooms(bot: discord.Client, payload: discord.RawReactionActionEvent):
"""Seul le propriétaire peut réagir ; on retire la réaction des autres."""
if payload.user_id == bot.user.id:
return
enabled, _ = _auto_rooms_config()
if not enabled:
return
room_info = _find_room_by_message(payload.message_id)
if not room_info:
return
guild_id, owner_id, room = room_info
if payload.user_id != owner_id:
try:
channel = bot.get_channel(payload.channel_id)
if channel:
msg = await channel.fetch_message(payload.message_id)
user = payload.member or await bot.fetch_user(payload.user_id)
await msg.remove_reaction(payload.emoji, user)
except discord.HTTPException:
pass
return
emoji_str = str(payload.emoji)
action = None
for e, a, _ in REACTIONS:
if e == emoji_str:
action = a
break
if not action:
return
# Canal = salon vocal (le message est dans la partie texte du vocal)
channel = bot.get_channel(payload.channel_id)
if not channel or not hasattr(channel, "send"):
return
await _handle_reaction_action(bot, guild_id, owner_id, action, channel)
try:
msg = await channel.fetch_message(payload.message_id)
user = payload.member or await bot.fetch_user(payload.user_id)
await msg.remove_reaction(payload.emoji, user)
except discord.HTTPException:
pass
+234
View File
@@ -0,0 +1,234 @@
# FreeLoot Discord : notifications depuis les flux LootScraper (Epic, Amazon Prime, GOG, Steam, etc.)
import asyncio
import logging
from discord import Client
from database import db
from database.helpers import ConfigurationHelper
from database.models import FreeLootEntry
from freeloot_feed import (
SOURCES,
fetch_feed,
source_key_from_entry,
game_name_from_title,
extract_image_from_content,
extract_description_from_content,
extract_valid_to_from_content,
extract_recommended_price_from_content,
extract_genres_from_content,
extract_rating_from_content,
)
def _get_mention_content() -> str:
"""Construit le contenu du message (mentions) depuis la config."""
raw = ConfigurationHelper().getValue("freeloot_mention")
if not raw or not str(raw).strip():
return ""
parts = []
for s in str(raw).strip().split(","):
s = s.strip()
if s == "everyone":
parts.append("@everyone")
elif s == "here":
parts.append("@here")
elif s.isdigit():
parts.append(f"<@&{s}>")
return " ".join(parts) if parts else ""
def _is_enabled_source(source_key: str) -> bool:
"""Vérifie si cette source est activée dans la config (freeloot_sources)."""
raw = ConfigurationHelper().getValue("freeloot_sources")
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
return True
enabled = [s.strip() for s in str(raw).split(",") if s.strip()]
return source_key in enabled if enabled else True
def _store_label_for_title(source_key: str) -> str:
"""Libellé court pour le titre style DraftBot (ex: 'l'Epic Games Store')."""
labels = {
"epic_pc": "l'Epic Games Store",
"epic_android": "l'Epic Games Store (Android)",
"epic_ios": "l'Epic Games Store (iOS)",
"amazon_prime": "Amazon Prime Gaming",
"gog": "GOG",
"google_play": "Google Play",
"apple_app_store": "l'App Store",
"steam": "Steam",
}
return labels.get(source_key, "la boutique")
# Logo (thumbnail) de chaque boutique pour l'embed Discord (affiché en haut à droite)
SOURCE_LOGO_URLS = {
"epic_pc": "https://store.epicgames.com/favicon.ico",
"epic_android": "https://store.epicgames.com/favicon.ico",
"epic_ios": "https://store.epicgames.com/favicon.ico",
"amazon_prime": "https://gaming.amazon.com/favicon.ico",
"gog": "https://www.gog.com/favicon.ico",
"google_play": "https://play.google.com/favicon.ico",
"apple_app_store": "https://www.apple.com/favicon.ico",
"steam": "https://store.steampowered.com/favicon.ico",
}
def _build_embed(entry: dict, source_key: str):
import discord
game_name = game_name_from_title(entry["title"])
source_label = next((s[1] for s in SOURCES if s[0] == source_key), source_key)
link = entry.get("link") or ""
content_raw = entry.get("content") or ""
img_url = extract_image_from_content(content_raw)
description = extract_description_from_content(content_raw, max_len=350)
valid_to = extract_valid_to_from_content(content_raw)
store_title = _store_label_for_title(source_key)
# Couleur barre gauche style DraftBot (orange-rouge)
color = 0xE67E22
title = f"{game_name} gratuit sur {store_title} !"
embed = discord.Embed(
title=title,
url=link if link.startswith("http") else None,
color=color,
)
if description:
embed.description = description
# Prix / gratuit / validité (Discord : pas de couleur dans le texte, seulement **gras** / markdown)
value_parts = ["**Gratuit**"]
if valid_to:
try:
from datetime import datetime
end = datetime.fromisoformat(valid_to.replace("Z", "+00:00"))
value_parts.append(f"jusqu'au {end.strftime('%d/%m/%Y')}")
except Exception:
value_parts.append(f"jusqu'au {valid_to[:10]}")
embed.add_field(
name="Prix",
value="".join(value_parts),
inline=False,
)
recommended_price = extract_recommended_price_from_content(content_raw)
if recommended_price:
embed.add_field(name="Prix recommandé", value=recommended_price, inline=True)
genres = extract_genres_from_content(content_raw)
if genres:
embed.add_field(name="Genres", value=genres, inline=True)
rating = extract_rating_from_content(content_raw)
if rating:
embed.add_field(name="Ratings", value=rating, inline=True)
if link and link.startswith("http"):
embed.add_field(
name="\u200b",
value=f"[Ouvrir dans la boutique !]({link})",
inline=False,
)
# Thumbnail (logo de la boutique en haut à droite)
logo_url = SOURCE_LOGO_URLS.get(source_key)
if logo_url and logo_url.startswith("http"):
embed.set_thumbnail(url=logo_url)
# Image principale (style DraftBot)
if img_url and img_url.startswith("http"):
embed.set_image(url=img_url)
embed.set_footer(text="MamieHenriette • FreeLoot")
return embed
_freeloot_first_check = True
async def checkFreeLootAndNotify(bot: Client):
global _freeloot_first_check
helper = ConfigurationHelper()
if not helper.getValue("freeloot_enable"):
return
channel_id = helper.getIntValue("freeloot_channel_id")
if not channel_id:
return
channel = bot.get_channel(channel_id)
if not channel:
logging.warning("FreeLoot: canal Discord introuvable")
return
entries = fetch_feed()
if not entries:
return
# Au premier check après le démarrage, on synchronise sans notifier
if _freeloot_first_check:
logging.info("FreeLoot: première vérification, synchronisation sans notification")
for entry in entries:
entry_id = entry["id"]
if not FreeLootEntry.query.get(entry_id):
source_key = source_key_from_entry(entry["title"], entry["link"])
if source_key and _is_enabled_source(source_key):
try:
db.session.add(FreeLootEntry(entry_id=entry_id))
db.session.commit()
except Exception as e:
logging.error(f"FreeLoot: erreur de synchronisation pour {entry_id}: {e}")
db.session.rollback()
_freeloot_first_check = False
return
# Vérifications suivantes : notification normale
for entry in entries:
entry_id = entry["id"]
if FreeLootEntry.query.get(entry_id):
continue
source_key = source_key_from_entry(entry["title"], entry["link"])
if not source_key or not _is_enabled_source(source_key):
continue
try:
embed = _build_embed(entry, source_key)
content = _get_mention_content()
await channel.send(content=content or None, embed=embed)
db.session.add(FreeLootEntry(entry_id=entry_id))
db.session.commit()
except Exception as e:
logging.error(f"FreeLoot: envoi Discord échoué pour {entry_id}: {e}")
db.session.rollback()
async def _send_entry_to_discord_async(bot: Client, entry_id: str) -> tuple[bool, str]:
"""
Envoie une entrée FreeLoot sur Discord (appel manuel). Retourne (succès, message).
"""
channel_id = ConfigurationHelper().getIntValue("freeloot_channel_id")
if not channel_id:
return (False, "Aucun canal Discord configuré pour FreeLoot.")
channel = bot.get_channel(channel_id)
if not channel:
return (False, "Canal Discord introuvable.")
entries = fetch_feed()
if not entries:
return (False, "Impossible de charger le flux.")
entry = next((e for e in entries if e.get("id") == entry_id), None)
if not entry:
return (False, "Entrée introuvable dans le flux.")
source_key = source_key_from_entry(entry["title"], entry["link"])
if not source_key:
return (False, "Source non reconnue pour cette entrée.")
try:
embed = _build_embed(entry, source_key)
content = _get_mention_content()
await channel.send(content=content or None, embed=embed)
if not FreeLootEntry.query.get(entry_id):
db.session.add(FreeLootEntry(entry_id=entry_id))
db.session.commit()
return (True, "Annonce envoyée sur Discord.")
except Exception as e:
logging.error(f"FreeLoot: envoi manuel échoué pour {entry_id}: {e}")
db.session.rollback()
return (False, str(e))
def send_entry_to_discord_sync(bot: Client, entry_id: str) -> tuple[bool, str]:
"""Appel synchrone pour envoyer une entrée sur Discord (depuis la webapp)."""
try:
future = asyncio.run_coroutine_threadsafe(
_send_entry_to_discord_async(bot, entry_id),
bot.loop,
)
return future.result(timeout=15)
except Exception as e:
logging.error(f"FreeLoot: send_entry_to_discord_sync: {e}")
return (False, str(e))
+15 -1
View File
@@ -8,6 +8,8 @@ from database.helpers import ConfigurationHelper
from database.models import GameBundle from database.models import GameBundle
from discord import Client from discord import Client
_humblebundle_first_check = True
def _isEnable(): def _isEnable():
helper = ConfigurationHelper() helper = ConfigurationHelper()
@@ -33,17 +35,29 @@ def _findFirstNotNotified(bundles) :
def _formatMessage(bundle): def _formatMessage(bundle):
choice = bundle['choices'][0] choice = bundle['choices'][0]
date = datetime.datetime.fromtimestamp(bundle['endDate']/1000,datetime.UTC).strftime("%d %B %Y") date = datetime.datetime.fromtimestamp(bundle['endDate']/1000,datetime.UTC).strftime("%d %B %Y")
message = f"@here **Humble Bundle** propose un pack de jeu [{bundle['name']}]({bundle['url']}) contenant :\n" message = f"**Humble Bundle** propose un pack de jeu [{bundle['name']}]({bundle['url']}) contenant :\n"
for game in choice["games"]: for game in choice["games"]:
message += f"- {game}\n" message += f"- {game}\n"
message += f"Pour {choice['price']}€, disponible jusqu'au {date}." message += f"Pour {choice['price']}€, disponible jusqu'au {date}."
return message return message
async def checkHumbleBundleAndNotify(bot: Client): async def checkHumbleBundleAndNotify(bot: Client):
global _humblebundle_first_check
if _isEnable() : if _isEnable() :
try : try :
bundles = _callGithub() bundles = _callGithub()
bundle = _findFirstNotNotified(bundles) bundle = _findFirstNotNotified(bundles)
# Premier check : synchronisation sans notification
if _humblebundle_first_check:
if bundle != None:
logging.info(f'HumbleBundle: première vérification, synchronisation sans notification pour {bundle["name"]}')
db.session.add(GameBundle(url=bundle['url'], name=bundle['name'], json = json.dumps(bundle)))
db.session.commit()
_humblebundle_first_check = False
return
# Vérifications normales ensuite
if bundle != None : if bundle != None :
message = _formatMessage(bundle) message = _formatMessage(bundle)
await bot.get_channel(ConfigurationHelper().getIntValue('humble_bundle_channel')).send(message) await bot.get_channel(ConfigurationHelper().getIntValue('humble_bundle_channel')).send(message)
+1225 -354
View File
File diff suppressed because it is too large Load Diff
+282
View File
@@ -0,0 +1,282 @@
import asyncio
import logging
import re
import xml.etree.ElementTree as ET
import requests
from discord import Client
from database import db
from database.helpers import ConfigurationHelper
from database.models import PatreonPost
from webapp import webapp
logger = logging.getLogger('patreon-notification')
logger.setLevel(logging.INFO)
_patreon_first_check = True
def _get_mention_content() -> str:
raw = ConfigurationHelper().getValue("patreon_mention")
if not raw or not str(raw).strip():
return ""
parts = []
for s in str(raw).strip().split(","):
s = s.strip()
if s == "everyone":
parts.append("@everyone")
elif s == "here":
parts.append("@here")
elif s.isdigit():
parts.append(f"<@&{s}>")
return " ".join(parts) if parts else ""
def _strip_html(html: str, max_len: int = 300) -> str:
"""Extrait le texte brut depuis du HTML et tronque."""
if not html:
return ""
text = re.sub(r'<br\s*/?>', '\n', html)
text = re.sub(r'<[^>]+>', '', text)
text = re.sub(r'&nbsp;', ' ', text)
text = re.sub(r'&amp;', '&', text)
text = re.sub(r'&lt;', '<', text)
text = re.sub(r'&gt;', '>', text)
text = re.sub(r'&#\d+;', '', text)
text = re.sub(r'\n{3,}', '\n\n', text).strip()
if len(text) > max_len:
text = text[:max_len].rsplit(' ', 1)[0] + '...'
return text
def _extract_image(html: str) -> str | None:
"""Extrait la première URL d'image depuis le contenu HTML."""
if not html:
return None
match = re.search(r'<img[^>]+src=["\']([^"\']+)["\']', html)
if match:
url = match.group(1)
if url.startswith("http"):
return url
return None
def _parse_item(item, creator_name: str) -> dict | None:
"""Parse un <item> RSS et retourne un dict avec les métadonnées."""
guid_elem = item.find('guid')
if guid_elem is None or not guid_elem.text:
return None
title_elem = item.find('title')
link_elem = item.find('link')
desc_elem = item.find('description')
pub_elem = item.find('pubDate')
return {
'guid': guid_elem.text.strip(),
'title': title_elem.text if title_elem is not None else 'Nouveau post',
'link': link_elem.text if link_elem is not None else '',
'description': desc_elem.text if desc_elem is not None else '',
'published_at': pub_elem.text if pub_elem is not None else '',
'creator': creator_name,
}
def _fetch_rss() -> tuple[list[dict], str] | None:
"""Fetch le RSS Patreon et retourne (posts, creator_name) ou None."""
helper = ConfigurationHelper()
creator = helper.getValue("patreon_creator")
if not creator or not str(creator).strip():
return None
rss_url = f"https://www.patreon.com/rss/{str(creator).strip()}"
try:
response = requests.get(rss_url, timeout=15)
except Exception as e:
logger.error(f"Patreon: erreur réseau lors de la récupération du RSS: {e}")
return None
if response.status_code != 200:
logger.error(f"Patreon: HTTP {response.status_code} pour {rss_url}")
return None
try:
root = ET.fromstring(response.content)
except ET.ParseError as e:
logger.error(f"Patreon: erreur de parsing XML: {e}")
return None
creator_name = creator
channel_elem = root.find('.//channel/title')
if channel_elem is not None and channel_elem.text:
creator_name = channel_elem.text
items = root.findall('.//item')
posts = []
for item in items:
parsed = _parse_item(item, creator_name)
if parsed:
posts.append(parsed)
return (posts, creator_name)
def _build_embed(post: dict):
import discord
title = post.get('title') or 'Nouveau post Patreon'
link = post.get('link') or ''
description = _strip_html(post.get('description') or '', max_len=350)
creator = post.get('creator') or 'Patreon'
image_url = _extract_image(post.get('description') or '')
helper = ConfigurationHelper()
try:
color = int(helper.getValue('patreon_embed_color') or 'F96854', 16)
except (ValueError, TypeError):
color = 0xF96854
embed = discord.Embed(
title=title,
url=link if link.startswith("http") else None,
color=color,
)
if description:
embed.description = description
embed.set_author(
name=creator,
icon_url="https://c5.patreon.com/external/favicon/favicon-32x32.png",
)
if image_url:
embed.set_image(url=image_url)
embed.set_footer(text="MamieHenriette \u2022 Patreon")
return embed
async def checkPatreonPosts(bot: Client):
global _patreon_first_check
with webapp.app_context():
helper = ConfigurationHelper()
if not helper.getValue("patreon_enable"):
return
channel_id = helper.getIntValue("patreon_channel_id")
if not channel_id:
return
channel = bot.get_channel(channel_id)
if not channel:
logger.warning("Patreon: canal Discord introuvable")
return
result = await asyncio.to_thread(_fetch_rss)
if not result:
return
posts, creator_name = result
if not posts:
logger.info("Patreon: aucun post trouvé dans le flux RSS")
return
if _patreon_first_check:
logger.info("Patreon: première vérification, synchronisation sans notification")
for post_data in posts:
guid = post_data['guid']
if not PatreonPost.query.get(guid):
try:
db.session.add(PatreonPost(
guid=guid,
title=post_data['title'],
link=post_data['link'],
description=post_data['description'],
published_at=post_data['published_at'],
notified=False,
))
db.session.commit()
except Exception as e:
logger.error(f"Patreon: erreur de synchronisation pour {guid}: {e}")
db.session.rollback()
_patreon_first_check = False
return
for post_data in posts:
guid = post_data['guid']
if PatreonPost.query.get(guid):
continue
try:
embed = _build_embed(post_data)
content = _get_mention_content()
await channel.send(content=content or None, embed=embed)
db.session.add(PatreonPost(
guid=guid,
title=post_data['title'],
link=post_data['link'],
description=post_data['description'],
published_at=post_data['published_at'],
notified=True,
))
db.session.commit()
logger.info(f"Patreon: notification envoyée pour '{post_data['title']}'")
except Exception as e:
logger.error(f"Patreon: envoi Discord échoué pour {guid}: {e}")
db.session.rollback()
async def _send_post_to_discord_async(bot: Client, guid: str) -> tuple[bool, str]:
"""Envoie un post Patreon sur Discord (appel manuel). Retourne (succès, message)."""
helper = ConfigurationHelper()
channel_id = helper.getIntValue("patreon_channel_id")
if not channel_id:
return (False, "Aucun canal Discord configuré pour Patreon.")
channel = bot.get_channel(channel_id)
if not channel:
return (False, "Canal Discord introuvable.")
post_db = PatreonPost.query.get(guid)
if not post_db:
return (False, "Post introuvable en base de données.")
creator = helper.getValue("patreon_creator") or "Patreon"
# Tenter de récupérer le nom du créateur depuis le RSS
result = _fetch_rss()
creator_name = result[1] if result else creator
post_data = {
'title': post_db.title or 'Nouveau post',
'link': post_db.link or '',
'description': post_db.description or '',
'creator': creator_name,
}
try:
embed = _build_embed(post_data)
content = _get_mention_content()
await channel.send(content=content or None, embed=embed)
post_db.notified = True
db.session.commit()
return (True, "Notification envoyée sur Discord.")
except Exception as e:
logger.error(f"Patreon: envoi manuel échoué pour {guid}: {e}")
db.session.rollback()
return (False, str(e))
def send_post_to_discord_sync(bot: Client, guid: str) -> tuple[bool, str]:
"""Appel synchrone pour envoyer un post sur Discord (depuis la webapp)."""
try:
future = asyncio.run_coroutine_threadsafe(
_send_post_to_discord_async(bot, guid),
bot.loop,
)
return future.result(timeout=15)
except Exception as e:
logger.error(f"Patreon: send_post_to_discord_sync: {e}")
return (False, str(e))
+123
View File
@@ -0,0 +1,123 @@
import logging
from typing import Any, List
import discord
from discord import app_commands
from database.helpers import ConfigurationHelper
from protondb import searhProtonDb
def _build_protondb_embed(games: List[Any]) -> discord.Embed:
total_games = len(games)
tier_colors = {'platinum': '🟣', 'gold': '🟡', 'silver': '', 'bronze': '🟤', 'borked': '🔴'}
content = ""
max_games = 15
for count, game in enumerate(games[:max_games]):
g_name = str(game.get('name'))
g_id = str(game.get('id'))
tier = str(game.get('tier') or 'N/A').lower()
tier_icon = tier_colors.get(tier, '')
new_entry = f"**[{g_name}](<https://www.protondb.com/app/{g_id}>)**\n{tier_icon} Classé **{tier.capitalize()}**"
ac_status = game.get('anticheat_status')
if ac_status:
status_lower = str(ac_status).lower()
ac_map = {
'supported': ('', 'Supporté'),
'running': ('⚠️', 'Fonctionne'),
'broken': ('', 'Cassé'),
'denied': ('🚫', 'Refusé'),
'planned': ('📅', 'Planifié')
}
ac_emoji, ac_label = ac_map.get(status_lower, ('', str(ac_status)))
acs = game.get('anticheats') or []
ac_list = ', '.join([str(ac) for ac in acs if ac])
new_entry += f" • [Anti-cheat {ac_emoji} {ac_label}"
if ac_list:
new_entry += f" ({ac_list})"
new_entry += f"](<https://areweanticheatyet.com/game/{g_id}>)"
new_entry += "\n\n"
if len(content) + len(new_entry) > 3900:
rest = len(games) - count
content += f"*... et {rest} autre{'s' if rest > 1 else ''} jeu{'x' if rest > 1 else ''}*"
break
content += new_entry
else:
rest = max(0, len(games) - max_games)
if rest > 0:
content += f"*... et {rest} autre{'s' if rest > 1 else ''} jeu{'x' if rest > 1 else ''}*"
return discord.Embed(
title=f"🎮 Résultats ProtonDB - **{total_games} jeu{'x' if total_games > 1 else ''} trouvé{'s' if total_games > 1 else ''}**",
description=content,
color=0x5865F2
)
async def _protondb_search_followup(interaction: discord.Interaction, query: str) -> None:
# Une seule réponse éditée (pas de followups en chaîne) : évite les fils « message introuvable »
# et supprime le besoin dun message séparé « Recherche en cours… ».
await interaction.response.defer()
try:
games = searhProtonDb(query)
except Exception as e:
logging.error(f"ProtonDB : searhProtonDb : {e}")
games = []
if len(games) == 0:
try:
await interaction.edit_original_response(
content=(
f"{interaction.user.mention} Je n'ai pas trouvé de jeux correspondant à **{query}**. "
"Es-tu sûr que le jeu est disponible sur Steam ?"
),
embed=None,
)
except Exception as e:
logging.error(f"ProtonDB : edit_original_response (vide) : {e}")
return
embed = _build_protondb_embed(games)
try:
await interaction.edit_original_response(content=None, embed=embed)
except Exception as e:
logging.error(f"ProtonDB : edit_original_response (embed) : {e}")
try:
await interaction.followup.send(embed=embed)
except Exception as e2:
logging.error(f"ProtonDB : followup de secours : {e2}")
async def _protondb_slash_impl(interaction: discord.Interaction, jeu: str, exemple: str) -> None:
if not ConfigurationHelper().getValue('proton_db_enable_enable'):
await interaction.response.send_message(
"❌ La commande ProtonDB n'est pas activée.",
ephemeral=True,
)
return
query = jeu.strip()
if not query:
await interaction.response.send_message(
f"⚠️ Indique le nom d'un jeu.\nExemple : `{exemple}`",
ephemeral=True,
)
return
await _protondb_search_followup(interaction, query)
@app_commands.command(name="protondb", description="Recherche un jeu sur ProtonDB (compatibilité Linux / Steam).")
@app_commands.describe(jeu="Nom du jeu (ex. Elden Ring)")
async def protondb_slash_command(interaction: discord.Interaction, jeu: str):
await _protondb_slash_impl(interaction, jeu, "/protondb jeu:Elden Ring")
@app_commands.command(name="pdb", description="Alias de /protondb — recherche un jeu sur ProtonDB.")
@app_commands.describe(jeu="Nom du jeu (ex. Elden Ring)")
async def pdb_slash_command(interaction: discord.Interaction, jeu: str):
await _protondb_slash_impl(interaction, jeu, "/pdb jeu:Elden Ring")
+218
View File
@@ -0,0 +1,218 @@
# Règlement Discord : rôle d'arrivée à la connexion, rôle validé au clic sur le bouton.
import asyncio
import logging
import discord
from discord import TextChannel
from discord.ui import Button, View
from webapp import webapp
from database import db
from database.helpers import ConfigurationHelper
RULES_BUTTON_CUSTOM_ID = "mamie_rules_accept"
DEFAULT_BUTTON_LABEL = "J'ai lu le règlement"
class AcceptRulesButton(Button):
def __init__(self, label: str):
super().__init__(
style=discord.ButtonStyle.success,
label=(label or DEFAULT_BUTTON_LABEL)[:80],
custom_id=RULES_BUTTON_CUSTOM_ID,
)
async def callback(self, interaction: discord.Interaction):
await handle_rules_accept(interaction)
class RulesAcceptView(View):
def __init__(self, button_label: str):
super().__init__(timeout=None)
self.add_item(AcceptRulesButton(button_label))
def register_persistent_rules_view(client: discord.Client) -> None:
with webapp.app_context():
label = (ConfigurationHelper().getValue("rules_button_label") or "").strip() or DEFAULT_BUTTON_LABEL
client.add_view(RulesAcceptView(label))
def _rules_ack_button_success_text(
validated_role: discord.Role,
presentation_ch: TextChannel | None,
) -> str:
base = f"c'est bon 😌 tu as maintenant le rôle **{validated_role.name}**."
if presentation_ch:
return f"{base} Tu peux aller te présenter dans {presentation_ch.mention}."
return base
async def handle_rules_accept(interaction: discord.Interaction) -> None:
if not interaction.guild:
await interaction.response.send_message("Action impossible dans ce contexte.", ephemeral=True)
return
try:
member = await interaction.guild.fetch_member(interaction.user.id)
except (discord.NotFound, discord.HTTPException):
member = interaction.user if isinstance(interaction.user, discord.Member) else None
if member is None:
await interaction.response.send_message("Action impossible dans ce contexte.", ephemeral=True)
return
with webapp.app_context():
config = ConfigurationHelper()
enabled = config.getValue("rules_ack_enable")
arrival_id = config.getIntValue("rules_arrival_role_id")
presentation_id = config.getIntValue("rules_presentation_channel_id")
validated_id = config.getIntValue("rules_validated_role_id")
if not enabled:
await interaction.response.send_message("Cette fonctionnalité est désactivée.", ephemeral=True)
return
if not validated_id:
await interaction.response.send_message("Rôle membre validé non configuré.", ephemeral=True)
return
validated_role = interaction.guild.get_role(validated_id)
if not validated_role:
await interaction.response.send_message("Rôle membre validé introuvable sur ce serveur.", ephemeral=True)
return
arrival_role = interaction.guild.get_role(arrival_id) if arrival_id else None
presentation_ch = interaction.guild.get_channel(presentation_id)
presentation_ch = presentation_ch if isinstance(presentation_ch, TextChannel) else None
success_text = _rules_ack_button_success_text(validated_role, presentation_ch)
# Le retrait du rôle d'arrivée est la marque persistante de l'acceptation.
# Le rôle validé peut ensuite être remplacé par le système de présentation ;
# dans ce cas, un nouveau clic ne doit surtout pas rejouer l'attribution.
if arrival_role and arrival_role not in member.roles:
await interaction.response.send_message(
"Tu as déjà accepté le règlement. Tes rôles ne seront pas modifiés.",
ephemeral=True,
)
return
if validated_role in member.roles:
await interaction.response.send_message(success_text, ephemeral=True)
return
try:
await member.add_roles(validated_role, reason="Acceptation du règlement (bouton)")
if arrival_role and arrival_role in member.roles:
await member.remove_roles(arrival_role, reason="Passage membre validé après charte")
except discord.Forbidden:
await interaction.response.send_message(
"Je n'ai pas la permission de modifier tes rôles (rôle du bot trop bas ou « Gérer les rôles » manquant).",
ephemeral=True,
)
return
except discord.HTTPException as e:
await interaction.response.send_message(f"Erreur Discord : {e}", ephemeral=True)
return
await interaction.response.send_message(success_text, ephemeral=True)
async def publish_rules_embed(bot: discord.Client) -> tuple[bool, str]:
with webapp.app_context():
config = ConfigurationHelper()
if not config.getValue("rules_ack_enable"):
return False, "Activez d'abord « Règlement avec bouton » et enregistrez la configuration."
channel_id = config.getIntValue("rules_channel_id")
body = (config.getValue("rules_embed_body") or "").strip()
title = (config.getValue("rules_embed_title") or "").strip() or "Bienvenue"
button_label = (config.getValue("rules_button_label") or "").strip() or DEFAULT_BUTTON_LABEL
old_mid = config.getIntValue("rules_message_id")
old_ch_id = config.getIntValue("rules_message_channel_id")
if not channel_id:
return False, "Choisissez un canal du règlement."
if not body:
return False, "Le texte du règlement est vide."
channel = bot.get_channel(channel_id)
if not channel or not isinstance(channel, TextChannel):
return False, "Canal du règlement introuvable."
if len(body) > 4096:
body = body[:4093] + "..."
embed = discord.Embed(title=title, description=body, color=discord.Color.blurple())
view = RulesAcceptView(button_label)
try:
if old_mid and old_ch_id:
old_ch = bot.get_channel(old_ch_id)
if old_ch and isinstance(old_ch, TextChannel):
try:
old_msg = await old_ch.fetch_message(old_mid)
await old_msg.delete()
except (discord.NotFound, discord.Forbidden, discord.HTTPException):
pass
msg = await channel.send(embed=embed, view=view)
with webapp.app_context():
ConfigurationHelper().createOrUpdate("rules_message_id", str(msg.id))
ConfigurationHelper().createOrUpdate("rules_message_channel_id", str(channel.id))
db.session.commit()
return True, "Message du règlement publié sur Discord."
except discord.Forbidden:
return False, "Permission refusée pour envoyer ou supprimer un message dans ce canal."
except Exception as e:
logging.exception("publish_rules_embed")
return False, str(e)
def publish_rules_embed_sync(bot: discord.Client) -> tuple[bool, str]:
try:
future = asyncio.run_coroutine_threadsafe(publish_rules_embed(bot), bot.loop)
return future.result(timeout=30)
except Exception as e:
logging.exception("publish_rules_embed_sync")
return False, str(e)
async def assign_rules_arrival_on_join(bot: discord.Client, member: discord.Member) -> None:
"""Attribue uniquement le rôle d'arrivée à la connexion (le rôle validé vient du bouton)."""
with webapp.app_context():
config = ConfigurationHelper()
if not config.getValue("rules_ack_enable"):
return
arrival_id = config.getIntValue("rules_arrival_role_id")
validated_id = config.getIntValue("rules_validated_role_id")
if not arrival_id:
return
guild = member.guild
arrival_role = guild.get_role(arrival_id)
if not arrival_role:
logging.warning("assign_rules_arrival_on_join: rôle d'arrivée %s introuvable sur %s", arrival_id, guild.id)
return
if validated_id:
validated_role = guild.get_role(validated_id)
if validated_role and validated_role in member.roles:
return
if arrival_role in member.roles:
return
try:
await member.add_roles(arrival_role, reason="Règlement : rôle d'arrivée à la connexion")
except discord.Forbidden:
logging.warning(
"assign_rules_arrival_on_join: permission refusée pour %s sur %s (hiérarchie des rôles ?)",
member.id,
guild.id,
)
except discord.HTTPException as e:
logging.warning("assign_rules_arrival_on_join: %s", e)
+7 -1
View File
@@ -100,8 +100,14 @@ async def sendWelcomeMessage(bot: discord.Client, member: Member):
embed.set_footer(text=f'ID: {member.id}') embed.set_footer(text=f'ID: {member.id}')
try: try:
await channel.send(embed=embed) message = await channel.send(embed=embed)
logging.info(f'Message de bienvenue envoyé pour {member.name}') logging.info(f'Message de bienvenue envoyé pour {member.name}')
now = datetime.now(timezone.utc)
account_age = (now - member.created_at).days
if account_age < 7:
await message.add_reaction('⚠️')
logging.info(f'Réaction warning ajoutée pour {member.name} (compte créé il y a {account_age} jours)')
except Exception as e: except Exception as e:
logging.error(f'Échec de l\'envoi du message de bienvenue : {e}') logging.error(f'Échec de l\'envoi du message de bienvenue : {e}')
+344
View File
@@ -0,0 +1,344 @@
import logging
import asyncio
import xml.etree.ElementTree as ET
import requests
import discord
from database import db
from database.models import YouTubeNotification
from webapp import webapp
logger = logging.getLogger('youtube-notification')
logger.setLevel(logging.INFO)
_youtube_first_check = True
async def checkYouTubeVideos():
global _youtube_first_check
with webapp.app_context():
try:
notifications: list[YouTubeNotification] = YouTubeNotification.query.filter_by(enable=True).all()
for notification in notifications:
try:
await _checkChannelVideos(notification, is_first_check=_youtube_first_check)
except Exception as e:
logger.error(f"Erreur lors de la vérification de la chaîne {notification.channel_id}: {e}")
db.session.rollback()
continue
if _youtube_first_check:
_youtube_first_check = False
logger.info("YouTube: première vérification terminée, notifications activées")
except Exception as e:
logger.error(f"Erreur lors de la vérification YouTube: {e}")
db.session.rollback()
def _extract_embed_config(notification: YouTubeNotification) -> dict:
"""Extrait toutes les valeurs ORM nécessaires à l'envoi dans un dict plain Python.
Doit être appelé pendant que le contexte Flask est actif."""
return {
'notify_channel': notification.notify_channel,
'message_template': notification.message or '',
'embed_title': notification.embed_title,
'embed_description': notification.embed_description,
'embed_color': notification.embed_color or 'FF0000',
'embed_footer': notification.embed_footer,
'embed_author_name': notification.embed_author_name,
'embed_author_icon': (notification.embed_author_icon or '').strip(),
'embed_thumbnail': bool(notification.embed_thumbnail),
'embed_image': bool(notification.embed_image),
}
async def _checkChannelVideos(notification: YouTubeNotification, is_first_check: bool = False):
try:
channel_id = notification.channel_id
rss_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
response = await asyncio.to_thread(requests.get, rss_url, timeout=10)
if response.status_code != 200:
logger.error(f"Erreur HTTP {response.status_code} lors de la récupération du RSS pour {channel_id}")
return
root = ET.fromstring(response.content)
ns = {'atom': 'http://www.w3.org/2005/Atom', 'yt': 'http://www.youtube.com/xml/schemas/2015', 'media': 'http://search.yahoo.com/mrss/'}
entries = root.findall('atom:entry', ns)
if not entries:
logger.warning(f"Aucune vidéo trouvée dans le RSS pour {channel_id}")
return
videos = []
for entry in entries:
video_id = entry.find('yt:videoId', ns)
if video_id is None:
continue
video_id = video_id.text
title_elem = entry.find('atom:title', ns)
video_title = title_elem.text if title_elem is not None else 'Sans titre'
link_elem = entry.find('atom:link', ns)
video_url = link_elem.get('href') if link_elem is not None else f"https://www.youtube.com/watch?v={video_id}"
published_elem = entry.find('atom:published', ns)
published_at = published_elem.text if published_elem is not None else ''
author_elem = entry.find('atom:author/atom:name', ns)
channel_name = author_elem.text if author_elem is not None else 'Inconnu'
thumbnail = None
media_thumbnail = entry.find('media:group/media:thumbnail', ns)
if media_thumbnail is not None:
thumbnail = media_thumbnail.get('url')
is_short = False
if video_title and ('#shorts' in video_title.lower() or '#short' in video_title.lower()):
is_short = True
video_data = {
'title': video_title,
'url': video_url,
'published': published_at,
'channel_name': channel_name,
'thumbnail': thumbnail,
'is_short': is_short
}
if notification.video_type == 'all':
videos.append((video_id, video_data))
elif notification.video_type == 'short' and is_short:
videos.append((video_id, video_data))
elif notification.video_type == 'video' and not is_short:
videos.append((video_id, video_data))
videos.sort(key=lambda x: x[1]['published'], reverse=True)
# Enregistrer toutes les vidéos du flux dans l'historique (les doublons sont ignorés)
for vid, vdata in videos:
_save_video_history(notification.id, vid, vdata, notified=False)
if not videos:
return
latest_video_id, _ = videos[0]
if is_first_check or not notification.last_video_id:
# Au démarrage, on initialise le curseur sans annoncer l'historique.
notification.last_video_id = latest_video_id
db.session.commit()
logger.info(f"YouTube: synchronisation initiale pour {channel_id}, dernière vidéo: {latest_video_id}")
return
if latest_video_id == notification.last_video_id:
return
# Une chaîne peut publier plusieurs vidéos entre deux contrôles : le choix
# fonctionnel est d'annoncer uniquement la plus récente, jamais l'historique.
logger.info(f"Nouvelle vidéo détectée: {latest_video_id} pour la chaîne {channel_id}")
embed_config = _extract_embed_config(notification)
success = await _notifyVideo(embed_config, videos[0][1], latest_video_id)
if not success:
# Ne pas avancer le curseur : cette dernière vidéo sera réessayée au prochain cycle.
logger.warning(f"Notification échouée pour {latest_video_id}; nouvel essai au prochain contrôle")
return
_save_video_history(notification.id, latest_video_id, videos[0][1], notified=True)
notification.last_video_id = latest_video_id
db.session.commit()
except Exception as e:
logger.error(f"Erreur lors de la vérification des vidéos: {e}")
db.session.rollback()
def _save_video_history(notification_id: int, video_id: str, video_data: dict, notified: bool):
"""Enregistre une vidéo dans l'historique (ne fait rien si déjà présente)."""
from database.models import YouTubeVideoHistory
try:
existing = YouTubeVideoHistory.query.filter_by(
notification_id=notification_id, video_id=video_id
).first()
if existing:
if notified and not existing.notified:
existing.notified = True
db.session.commit()
return
entry = YouTubeVideoHistory(
notification_id=notification_id,
video_id=video_id,
title=video_data.get('title', 'Sans titre'),
url=video_data.get('url', f"https://www.youtube.com/watch?v={video_id}"),
channel_name=video_data.get('channel_name', 'Inconnu'),
thumbnail=video_data.get('thumbnail'),
published_at=video_data.get('published', ''),
is_short=video_data.get('is_short', False),
notified=notified,
)
db.session.add(entry)
db.session.commit()
except Exception as e:
logger.error(f"Erreur lors de l'enregistrement de l'historique vidéo: {e}")
db.session.rollback()
async def _notifyVideo(embed_config: dict, video_data: dict, video_id: str) -> bool:
"""Envoie la notification Discord. Retourne True si l'envoi a réussi."""
from discordbot import bot
try:
channel_name = video_data.get('channel_name', 'Inconnu')
video_title = video_data.get('title', 'Sans titre')
video_url = video_data.get('url', f"https://www.youtube.com/watch?v={video_id}")
thumbnail = video_data.get('thumbnail', '')
published_at = video_data.get('published', '')
is_short = video_data.get('is_short', False)
message_template = embed_config.get('message_template', '')
try:
message = message_template.format(
channel_name=channel_name or 'Inconnu',
video_title=video_title or 'Sans titre',
video_url=video_url,
video_id=video_id,
thumbnail=thumbnail or '',
published_at=published_at or '',
is_short=is_short
)
except (KeyError, AttributeError, ValueError) as e:
logger.error(f"Erreur de formatage du message: {e}")
message = f"🎥 Nouvelle vidéo de {channel_name}: [{video_title}]({video_url})"
logger.info(f"Envoi de notification YouTube: {message}")
return await _sendMessage(embed_config, message, video_url, thumbnail, video_title, channel_name, video_id, published_at, is_short)
except Exception as e:
logger.error(f"Erreur lors de la notification: {e}")
return False
def _format_embed_text(text: str, channel_name: str, video_title: str, video_url: str, video_id: str, thumbnail: str, published_at: str, is_short: bool) -> str:
"""Formate un texte d'embed avec les variables disponibles"""
if not text:
return None
try:
return text.format(
channel_name=channel_name or 'Inconnu',
video_title=video_title or 'Sans titre',
video_url=video_url,
video_id=video_id,
thumbnail=thumbnail or '',
published_at=published_at or '',
is_short=is_short
)
except KeyError:
return text
async def _sendMessage(embed_config: dict, message: str, video_url: str, thumbnail: str, video_title: str, channel_name: str, video_id: str, published_at: str, is_short: bool) -> bool:
"""Envoie le message Discord. Retourne True si l'envoi a réussi."""
from discordbot import bot
try:
channel_id = int(embed_config['notify_channel'])
discord_channel = bot.get_channel(channel_id)
if not discord_channel:
# Le salon peut ne pas être présent dans le cache local après une reconnexion.
discord_channel = await bot.fetch_channel(channel_id)
if not discord_channel:
logger.error(f"Canal Discord {channel_id} introuvable")
return False
embed_title_text = _format_embed_text(embed_config['embed_title'], channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short) if embed_config['embed_title'] else video_title
embed_description = _format_embed_text(embed_config['embed_description'], channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short) if embed_config['embed_description'] else None
try:
embed_color = int(embed_config['embed_color'], 16)
except ValueError:
embed_color = 0xFF0000
embed = discord.Embed(
title=embed_title_text,
url=video_url,
color=embed_color
)
if embed_description:
embed.description = embed_description
author_name = _format_embed_text(embed_config['embed_author_name'], channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short) if embed_config['embed_author_name'] else channel_name
author_icon_raw = embed_config['embed_author_icon']
author_icon = author_icon_raw if author_icon_raw.startswith(("http://", "https://")) else "https://www.youtube.com/img/desktop/yt_1200.png"
embed.set_author(name=author_name, icon_url=author_icon)
if embed_config['embed_thumbnail'] and thumbnail:
embed.set_thumbnail(url=thumbnail)
if embed_config['embed_image'] and thumbnail:
embed.set_image(url=thumbnail)
if embed_config['embed_footer']:
footer_text = _format_embed_text(embed_config['embed_footer'], channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short)
if footer_text:
embed.set_footer(text=footer_text)
if message and message.strip():
await discord_channel.send(message, embed=embed)
else:
await discord_channel.send(embed=embed)
logger.info(f"Notification YouTube envoyée avec succès")
return True
except Exception as e:
logger.error(f"Erreur lors de l'envoi du message Discord: {e}")
return False
async def _send_video_notification_async(history_id: int) -> tuple[bool, str]:
"""Force l'envoi d'une notification pour une vidéo de l'historique. Retourne (succès, message)."""
from database.models import YouTubeVideoHistory
with webapp.app_context():
history = YouTubeVideoHistory.query.get(history_id)
if not history:
return (False, "Vidéo introuvable dans l'historique.")
notification = YouTubeNotification.query.get(history.notification_id)
if not notification:
return (False, "Notification YouTube associée introuvable.")
embed_config = _extract_embed_config(notification)
video_data = {
'title': history.title or 'Sans titre',
'url': history.url or f"https://www.youtube.com/watch?v={history.video_id}",
'channel_name': history.channel_name or 'Inconnu',
'thumbnail': history.thumbnail or '',
'published': history.published_at or '',
'is_short': history.is_short,
}
success = await _notifyVideo(embed_config, video_data, history.video_id)
if success:
history.notified = True
db.session.commit()
return (True, "Notification envoyée sur Discord.")
else:
db.session.rollback()
return (False, "Échec de l'envoi sur Discord.")
def send_video_notification_sync(history_id: int) -> tuple[bool, str]:
"""Appel synchrone pour forcer une notification (depuis la webapp)."""
from discordbot import bot
try:
future = asyncio.run_coroutine_threadsafe(
_send_video_notification_async(history_id),
bot.loop,
)
return future.result(timeout=15)
except Exception as e:
logger.error(f"send_video_notification_sync: {e}")
return (False, str(e))
+214
View File
@@ -0,0 +1,214 @@
# Module partagé : récupération et parsing du flux LootScraper (sans dépendance Discord)
import re
import xml.etree.ElementTree as ET
from html import unescape
import requests
FEED_URL = "https://feed.eikowagenknecht.com/lootscraper.xml"
STEAM_FEED_URL = "https://feed.eikowagenknecht.com/lootscraper_steam_game.xml"
FEED_URLS = (FEED_URL, STEAM_FEED_URL)
ATOM_NS = {"atom": "http://www.w3.org/2005/Atom"}
SOURCES = [
("epic_pc", "Epic Games (PC)", "🖥️"),
("epic_android", "Epic Games (Android)", "🤖"),
("epic_ios", "Epic Games (iOS)", "🍎"),
("amazon_prime", "Amazon Prime Gaming", "📦"),
("gog", "GOG", "🎮"),
("google_play", "Google Play", "🤖"),
("apple_app_store", "Apple App Store", "🍎"),
("steam", "Steam", "🎮"),
]
def source_key_from_entry(title: str, link: str) -> str | None:
"""Détermine la clé source+plateforme depuis le titre et le lien."""
title_upper = (title or "").upper()
link_lower = (link or "").lower()
if "EPIC GAMES" in title_upper:
if "-ios-" in link_lower or "/ios-" in link_lower:
return "epic_ios"
if "-android-" in link_lower or "/android-" in link_lower:
return "epic_android"
return "epic_pc"
if "AMAZON PRIME" in title_upper:
return "amazon_prime"
if "GOG" in title_upper:
return "gog"
if "GOOGLE PLAY" in title_upper:
return "google_play"
if "APPLE APP STORE" in title_upper:
return "apple_app_store"
if "STEAM" in title_upper or "store.steampowered.com" in link_lower:
return "steam"
return None
def game_name_from_title(title: str) -> str:
"""Extrait le nom du jeu depuis le titre."""
if not title:
return "Jeu gratuit"
for prefix in (
"Epic Games (Game) - ",
"Amazon Prime (Game) - ",
"GOG (Game) - ",
"GOG (Game, Always Free) - ",
"Google Play (Game) - ",
"Apple App Store (Game) - ",
"Steam (Game, PC) - ",
):
if title.startswith(prefix):
return unescape(title[len(prefix) :].strip())
if " - " in title:
return unescape(title.split(" - ", 1)[1].strip())
return unescape(title.strip())
def extract_image_from_content(content: str) -> str | None:
"""Extrait l'URL de la première image du contenu HTML (toutes balises img)."""
if not content:
return None
def _normalize_url(url: str) -> str:
u = unescape(url.strip()).replace("&amp;", "&")
return u
# 1) Balises <img ... src="url" ...>
for m in re.finditer(r'<img[^>]+src\s*=\s*["\']([^"\']+)["\']', content, re.I):
url = _normalize_url(m.group(1))
if url.startswith("http"):
return url
# 2) Fallback: toute attribution src="http..." (certains CDN n'ont pas d'extension)
for m in re.finditer(r'src\s*=\s*["\'](https?://[^"\']{20,})["\']', content, re.I):
url = _normalize_url(m.group(1))
if url.startswith("http"):
return url
return None
def extract_description_from_content(content: str, max_len: int = 400) -> str | None:
"""Extrait la description du jeu depuis le contenu HTML (balise <b>Description:</b>)."""
if not content:
return None
m = re.search(r"<b>Description:</b>\s*([^<]+)", content, re.I | re.DOTALL)
if not m:
return None
desc = unescape(m.group(1).strip())
desc = re.sub(r"\s+", " ", desc)
if len(desc) > max_len:
desc = desc[: max_len - 3].rsplit(" ", 1)[0] + "..."
return desc or None
def extract_valid_to_from_content(content: str) -> str | None:
"""Extrait la date 'Offer valid to' depuis le contenu HTML."""
if not content:
return None
m = re.search(r"<b>Offer valid to:</b>\s*(\d{4}-\d{2}-\d{2}[^<]*)", content, re.I)
return m.group(1).strip() if m else None
def extract_recommended_price_from_content(content: str) -> str | None:
"""Extrait le prix recommandé (ex: '39.99 EUR') depuis le contenu HTML."""
if not content:
return None
m = re.search(r"<b>Recommended price\s*\([^)]*\):\s*</b>\s*([^<]+)", content, re.I)
if not m:
m = re.search(r"Recommended price[^<]*</b>\s*([^<]+)", content, re.I)
return unescape(m.group(1).strip()) if m else None
def extract_genres_from_content(content: str) -> str | None:
"""Extrait les genres (ex: 'Action, Indie') depuis le contenu HTML."""
if not content:
return None
m = re.search(r"<b>Genres:</b>\s*([^<]+)", content, re.I)
return unescape(m.group(1).strip()) if m else None
def extract_rating_from_content(content: str) -> str | None:
"""Extrait le rating (texte après Ratings:, liens ou texte brut)."""
if not content:
return None
m = re.search(r"<b>Ratings:</b>\s*(.+?)</li>", content, re.I | re.DOTALL)
if not m:
return None
raw = m.group(1)
raw = re.sub(r"<a[^>]*>([^<]*)</a>", r"\1", raw)
raw = re.sub(r"<[^>]+>", " ", raw)
raw = unescape(re.sub(r"\s+", " ", raw).strip())
return raw if len(raw) > 0 and len(raw) < 200 else None
def _fetch_single_feed(feed_url: str) -> list[dict]:
"""Récupère et parse un flux Atom LootScraper."""
try:
r = requests.get(feed_url, timeout=15)
r.raise_for_status()
root = ET.fromstring(r.content)
entries = []
for entry_el in root.findall(".//atom:entry", ATOM_NS):
entry_id_el = entry_el.find("atom:id", ATOM_NS)
title_el = entry_el.find("atom:title", ATOM_NS)
link_el = entry_el.find("atom:link", ATOM_NS)
content_el = entry_el.find("atom:content", ATOM_NS)
updated_el = entry_el.find("atom:updated", ATOM_NS) or entry_el.find("atom:published", ATOM_NS)
entry_id = entry_id_el.text.strip() if entry_id_el is not None and entry_id_el.text else None
title = title_el.text.strip() if title_el is not None and title_el.text else None
link = link_el.get("href") if link_el is not None else None
content = ""
if content_el is not None:
# Sérialiser tout le sous-arbre pour ne rien perdre (notamment les <img>)
content = ET.tostring(content_el, encoding="unicode", method="xml")
# Normaliser les préfixes de namespace (ex: <html:b> -> <b>) pour que les regex d'extraction matchent
content = content.replace("</html:", "</").replace("<html:", "<")
updated = updated_el.text.strip() if updated_el is not None and updated_el.text else None
if entry_id and title:
entries.append({
"id": entry_id,
"title": title,
"link": link or "",
"content": content or "",
"updated": updated,
})
return entries
except Exception:
return []
def fetch_feed() -> list[dict] | None:
"""Récupère les flux LootScraper général et Steam."""
entries = []
for feed_url in FEED_URLS:
entries.extend(_fetch_single_feed(feed_url))
return entries or None
def get_display_entries() -> list[dict]:
"""Retourne la liste des entrées formatées pour affichage (webapp)."""
raw = fetch_feed()
if not raw:
return []
result = []
for e in raw:
sk = source_key_from_entry(e["title"], e["link"])
content = e.get("content") or ""
desc = extract_description_from_content(content)
result.append({
"id": e["id"],
"title": e["title"],
"link": e["link"],
"game_name": game_name_from_title(e["title"]),
"source_key": sk or "other",
"source_label": next((s[1] for s in SOURCES if s[0] == sk), sk or "Autre"),
"emoji": next((s[2] for s in SOURCES if s[0] == sk), "🎁"),
"image_url": extract_image_from_content(content),
"description": desc,
"valid_to": extract_valid_to_from_content(content),
"recommended_price": extract_recommended_price_from_content(content),
"genres": extract_genres_from_content(content),
"rating": extract_rating_from_content(content),
"updated": e.get("updated"),
})
return result
+1
View File
@@ -11,6 +11,7 @@ twitchAPI>=4.5.0
# Nécessaire pour l'hébergement du site web # Nécessaire pour l'hébergement du site web
flask>=2.3.2 flask>=2.3.2
flask-login>=0.6.3
flask-sqlalchemy>=3.0.3 flask-sqlalchemy>=3.0.3
flask[async] flask[async]
waitress>=3.0.2 waitress>=3.0.2
+300 -19
View File
@@ -1,72 +1,353 @@
import asyncio import asyncio
import logging import logging
import time
from twitchAPI.twitch import Twitch from twitchAPI.twitch import Twitch
from twitchAPI.type import AuthScope, ChatEvent from twitchAPI.type import AuthScope, ChatEvent
from twitchAPI.chat import Chat, ChatEvent, ChatMessage, EventData from twitchAPI.chat import Chat, ChatEvent, ChatMessage, EventData
from database.helpers import ConfigurationHelper from database.helpers import ConfigurationHelper
from database.models import Commande
def _user_has_twitch_permission(msg: ChatMessage, required: str) -> bool:
"""Vérifie si l'utilisateur a le niveau de permission requis (viewer, sub, vip, moderator)."""
if not required or required == 'viewer':
return True
is_broadcaster = msg.user.name.lower() == msg.room.name.lower()
is_mod = msg.user.mod or is_broadcaster
if required == 'moderator':
return is_mod
if required == 'vip':
return msg.user.vip or is_mod
if required == 'sub':
return msg.user.subscriber or msg.user.vip or is_mod
return True
from twitchbot.live_alert import checkOnlineStreamer from twitchbot.live_alert import checkOnlineStreamer
from twitchbot.announcements import checkAndSendAnnouncements, incrementMessageCount
from twitchbot import moderation
from twitchbot import link_filter
from twitchbot import event_notifications
from webapp import webapp from webapp import webapp
USER_SCOPE = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT] USER_SCOPE = [
AuthScope.CHAT_READ,
AuthScope.CHAT_EDIT,
AuthScope.MODERATOR_MANAGE_BANNED_USERS,
AuthScope.MODERATOR_MANAGE_CHAT_MESSAGES,
AuthScope.MODERATOR_MANAGE_CHAT_SETTINGS,
AuthScope.MODERATOR_MANAGE_SHIELD_MODE,
AuthScope.CHANNEL_MANAGE_BROADCAST,
AuthScope.MODERATOR_READ_FOLLOWERS,
AuthScope.CHANNEL_READ_SUBSCRIPTIONS, # EventSub channel.subscribe (notifs sub)
]
async def _onReady(ready_event: EventData): async def _onReady(ready_event: EventData):
logging.info('Bot Twitch prêt') logging.info('Bot Twitch prêt')
twitchBot._loop = asyncio.get_running_loop()
with webapp.app_context(): with webapp.app_context():
await ready_event.chat.join_room(ConfigurationHelper().getValue('twitch_channel')) channel = ConfigurationHelper().getValue('twitch_channel')
webapp.config["BOT_STATUS"]["twitch_connected"] = True
webapp.config["BOT_STATUS"]["twitch_channel_name"] = channel
await ready_event.chat.join_room(channel)
# EventSub (follow, sub, raid) : besoin du broadcaster_id
try:
from twitchAPI.helper import first
user = await first(twitchBot.twitch.get_users(logins=[channel]))
if user:
twitchBot._eventsub = event_notifications.create_eventsub(twitchBot.twitch, asyncio.get_event_loop())
asyncio.create_task(event_notifications.register_eventsub_handlers(twitchBot._eventsub, user.id, ready_event.chat, channel))
except Exception as e:
logging.warning('EventSub non démarré: %s', e)
asyncio.get_event_loop().create_task(twitchBot._checkOnlineStreamers()) asyncio.get_event_loop().create_task(twitchBot._checkOnlineStreamers())
asyncio.get_event_loop().create_task(twitchBot._runAnnouncements())
asyncio.get_event_loop().create_task(twitchBot._checkClips())
async def _onMessage(msg: ChatMessage): async def _onMessage(msg: ChatMessage):
logging.info(f'Dans {msg.room.name}, {msg.user.name} a dit : {msg.text}') logging.info(f'Dans {msg.room.name}, {msg.user.name} a dit : {msg.text}')
incrementMessageCount()
# Stocker le message dans BOT_STATUS pour l'affichage web
with webapp.app_context():
from datetime import datetime
now_ts = time.time()
msg_timestamps = webapp.config["BOT_STATUS"].setdefault("twitch_msg_timestamps", [])
msg_timestamps.append(now_ts)
cutoff = now_ts - 60
webapp.config["BOT_STATUS"]["twitch_msg_timestamps"] = [ts for ts in msg_timestamps if ts >= cutoff]
webapp.config["BOT_STATUS"]["twitch_msg_per_minute"] = len(webapp.config["BOT_STATUS"]["twitch_msg_timestamps"])
message_data = {
'username': msg.user.name,
'text': msg.text,
'timestamp': datetime.now().isoformat(),
'is_mod': msg.user.mod,
'is_subscriber': msg.user.subscriber,
'is_vip': msg.user.vip,
'color': getattr(msg.user, 'color', None) or '#9146FF'
}
messages = webapp.config["BOT_STATUS"]["twitch_chat_messages"]
messages.append(message_data)
# Limiter à 100 messages
if len(messages) > 100:
messages.pop(0)
if not await link_filter.check_message_for_links(msg, twitchBot.twitch):
return
if not await moderation.check_message_for_banned_words(msg, twitchBot.twitch):
return
await _handleCustomCommand(msg)
async def _handleCustomCommand(msg: ChatMessage):
if not msg.text.startswith('!'):
return
trigger = msg.text.split()[0].lower()
with webapp.app_context():
# Vérifier si les commandes Twitch sont activées globalement
if not ConfigurationHelper().getValue('twitch_commands_enable'):
return
commande = Commande.query.filter_by(trigger=trigger, twitch_enable=True).first()
if commande:
permission = commande.twitch_permission or 'viewer'
if not _user_has_twitch_permission(msg, permission):
return
response = _replace_command_variables(commande.response, msg)
await msg.reply(response)
def _replace_command_variables(text: str, msg: ChatMessage) -> str:
"""Remplace les variables de template dans la réponse d'une commande."""
from datetime import datetime
result = text
result = result.replace('{user}', msg.user.name)
result = result.replace('{username}', msg.user.name)
result = result.replace('{channel}', msg.room.name)
bot_status = webapp.config.get("BOT_STATUS", {})
result = result.replace('{title}', bot_status.get("twitch_stream_title", ""))
result = result.replace('{game}', bot_status.get("twitch_game_name", ""))
result = result.replace('{viewers}', str(bot_status.get("twitch_viewer_count", 0)))
uptime_str = "hors ligne"
started_at_str = bot_status.get("twitch_started_at")
if started_at_str and bot_status.get("twitch_is_live", False):
try:
started_at = datetime.fromisoformat(started_at_str)
delta = datetime.now(started_at.tzinfo) - started_at
total_seconds = int(delta.total_seconds())
hours, remainder = divmod(max(0, total_seconds), 3600)
minutes, _ = divmod(remainder, 60)
if hours > 0:
uptime_str = f"{hours}h {minutes:02d}min"
else:
uptime_str = f"{minutes}min"
except (ValueError, TypeError):
pass
result = result.replace('{uptime}', uptime_str)
return result
# commande qui répond "bonjour" à "!hello"
async def _helloCommand(msg: ChatMessage): async def _helloCommand(msg: ChatMessage):
await msg.reply(f'Bonjour {msg.user.name}') await msg.reply(f'Bonjour {msg.user.name}')
def _isConfigured() -> bool:
helper = ConfigurationHelper()
return helper.getValue('twitch_client_id') != None and helper.getValue('twitch_client_secret') != None and helper.getValue('twitch_access_token') != None and helper.getValue('twitch_refresh_token') != None and helper.getValue('twitch_channel') != None
class TwitchBot() : def _isConfigured():
helper = ConfigurationHelper()
return (helper.getValue('twitch_client_id') is not None and
helper.getValue('twitch_client_secret') is not None and
helper.getValue('twitch_access_token') is not None and
helper.getValue('twitch_refresh_token') is not None and
helper.getValue('twitch_channel') is not None)
class TwitchBot():
_eventsub = None
_loop = None
async def _connect(self): async def _connect(self):
with webapp.app_context(): with webapp.app_context():
if _isConfigured() : if _isConfigured():
try : try:
helper = ConfigurationHelper() helper = ConfigurationHelper()
self.twitch = await Twitch(helper.getValue('twitch_client_id'), helper.getValue('twitch_client_secret')) self.twitch = await Twitch(helper.getValue('twitch_client_id'), helper.getValue('twitch_client_secret'))
await self.twitch.set_user_authentication(helper.getValue('twitch_access_token'), USER_SCOPE, helper.getValue('twitch_refresh_token')) await self.twitch.set_user_authentication(helper.getValue('twitch_access_token'), USER_SCOPE, helper.getValue('twitch_refresh_token'))
self.chat = await Chat(self.twitch) self.chat = await Chat(self.twitch)
# Laisser des tentatives de reconnexion internes plus longues avant reboot complet du client
self.chat.reconnect_delay_steps = [0, 1, 2, 4, 8, 16, 32, 64, 128, 128, 128]
self.chat.register_event(ChatEvent.READY, _onReady) self.chat.register_event(ChatEvent.READY, _onReady)
self.chat.register_event(ChatEvent.MESSAGE, _onMessage) self.chat.register_event(ChatEvent.MESSAGE, _onMessage)
# chat.register_event(ChatEvent.SUB, on_sub)
self.chat.register_command('hello', _helloCommand) self.chat.register_command('hello', _helloCommand)
self._register_moderation_commands()
self.chat.start() self.chat.start()
disconnected_since = None
while True:
connected = self.chat.is_connected()
if connected:
disconnected_since = None
else:
if disconnected_since is None:
disconnected_since = time.time()
# Si la lib n'arrive pas à se reconnecter en interne pendant un moment, on relance la session complète.
elif time.time() - disconnected_since >= 90:
logging.warning("Chat Twitch déconnecté depuis plus de 90s, redémarrage de la session")
break
await asyncio.sleep(2)
except Exception as e: except Exception as e:
logging.error(f'Échec de l\'authentification Twitch. Vérifiez vos identifiants et redémarrez après correction : {e}') logging.error(f'Échec de l\'authentification Twitch : {e}')
finally:
webapp.config["BOT_STATUS"]["twitch_connected"] = False
self._loop = None
try:
if hasattr(self, 'chat') and self.chat:
self.chat.stop()
except Exception:
pass
try:
if hasattr(self, 'twitch') and self.twitch:
await self.twitch.close()
except Exception:
pass
else: else:
logging.info("Twitch n'est pas configuré") logging.info("Twitch n'est pas configuré")
def _register_moderation_commands(self):
# Créer des wrappers async pour chaque commande
async def cmd_timeout(msg): await moderation.timeout_command(msg, self.twitch)
async def cmd_ban(msg): await moderation.ban_command(msg, self.twitch)
async def cmd_unban(msg): await moderation.unban_command(msg, self.twitch)
async def cmd_clean(msg): await moderation.clean_command(msg, self.twitch)
async def cmd_shieldmode(msg): await moderation.shieldmode_command(msg, self.twitch)
async def cmd_settitle(msg): await moderation.settitle_command(msg, self.twitch)
async def cmd_setgame(msg): await moderation.setgame_command(msg, self.twitch)
async def cmd_subon(msg): await moderation.subon_command(msg, self.twitch)
async def cmd_suboff(msg): await moderation.suboff_command(msg, self.twitch)
async def cmd_follon(msg): await moderation.follon_command(msg, self.twitch)
async def cmd_folloff(msg): await moderation.folloff_command(msg, self.twitch)
async def cmd_emoteon(msg): await moderation.emoteon_command(msg, self.twitch)
async def cmd_emoteoff(msg): await moderation.emoteoff_command(msg, self.twitch)
async def cmd_ann(msg): await moderation.ann_command(msg, self.twitch)
async def cmd_no_game(msg): await moderation.no_game_command(msg, self.twitch)
async def cmd_multitwitch(msg): await moderation.multitwitch_command(msg, self.twitch)
async def cmd_permit(msg): await link_filter.permit_command(msg, self.twitch)
from twitchbot import protondb as protondb_twitch
async def cmd_pdb(msg): await protondb_twitch.protondb_command(msg)
self.chat.register_command('kick', cmd_timeout)
self.chat.register_command('to', cmd_timeout)
self.chat.register_command('timeout', cmd_timeout)
self.chat.register_command('tm', cmd_timeout)
self.chat.register_command('ban', cmd_ban)
self.chat.register_command('unban', cmd_unban)
self.chat.register_command('clean', cmd_clean)
self.chat.register_command('shieldmode', cmd_shieldmode)
self.chat.register_command('settitle', cmd_settitle)
self.chat.register_command('setgame', cmd_setgame)
self.chat.register_command('setcateg', cmd_setgame)
self.chat.register_command('subon', cmd_subon)
self.chat.register_command('suboff', cmd_suboff)
self.chat.register_command('follon', cmd_follon)
self.chat.register_command('folloff', cmd_folloff)
self.chat.register_command('emoteon', cmd_emoteon)
self.chat.register_command('emoteoff', cmd_emoteoff)
self.chat.register_command('ann', cmd_ann)
self.chat.register_command('no_game', cmd_no_game)
self.chat.register_command('multitwitch', cmd_multitwitch)
self.chat.register_command('permit', cmd_permit)
self.chat.register_command('pdb', cmd_pdb)
self.chat.register_command('protondb', cmd_pdb)
async def _checkOnlineStreamers(self): async def _checkOnlineStreamers(self):
# pas bon faudrait faire un truc mieux while True:
while True :
try: try:
await checkOnlineStreamer(self.twitch) await checkOnlineStreamer(self.twitch)
except Exception as e: except Exception as e:
logging.error(f'Erreur lors lors du check des streamers online : {e}') logging.error(f'Erreur check streamers online : {e}')
# toutes les 5 minutes await asyncio.sleep(5 * 60)
await asyncio.sleep(5*60)
async def _runAnnouncements(self):
with webapp.app_context():
channel = ConfigurationHelper().getValue('twitch_channel')
while True:
try:
await checkAndSendAnnouncements(self.chat, channel, self.twitch)
except Exception as e:
logging.error(f'Erreur envoi annonces : {e}')
await asyncio.sleep(2 * 60) # Vérification toutes les 2 min pour limiter le spam
async def _checkClips(self):
"""Vérifie le clip le plus récent (polling) et notifie si nouveau."""
with webapp.app_context():
channel = ConfigurationHelper().getValue('twitch_channel')
if not channel:
return
from twitchAPI.helper import first
try:
user = await first(self.twitch.get_users(logins=[channel]))
if not user:
return
except Exception:
return
while True:
try:
with webapp.app_context():
from database.models import TwitchEventNotification
cfg = TwitchEventNotification.query.filter_by(event_type='clip', enable=True).first()
if not cfg:
await asyncio.sleep(120)
continue
clip = await first(self.twitch.get_clips(broadcaster_id=user.id, first=1))
if not clip:
await asyncio.sleep(120)
continue
if cfg.last_clip_id is None:
with webapp.app_context():
from database import db
cfg = TwitchEventNotification.query.filter_by(event_type='clip', enable=True).first()
if cfg:
cfg.last_clip_id = clip.id
db.session.commit()
elif clip.id != cfg.last_clip_id:
with webapp.app_context():
await event_notifications.notify_clip(
self.chat,
channel,
user=getattr(clip, 'creator_name', None) or getattr(clip, 'user_name', '') or clip.id,
title=getattr(clip, 'title', '') or 'Clip',
url=getattr(clip, 'url', '') or f'https://clips.twitch.tv/{clip.id}',
thumbnail_url=getattr(clip, 'thumbnail_url', '') or '',
clip_id=clip.id,
)
except Exception as e:
logging.error('Erreur check clips: %s', e)
await asyncio.sleep(120)
def begin(self): def begin(self):
retry_delay = 15
while True:
try:
if not _isConfigured():
logging.info("Twitch non configuré, nouvelle tentative dans 60s")
with webapp.app_context():
webapp.config["BOT_STATUS"]["twitch_connected"] = False
time.sleep(60)
continue
asyncio.run(self._connect()) asyncio.run(self._connect())
logging.warning("Session Twitch perdue, reconnexion complète dans %ss", retry_delay)
except Exception as e:
logging.error("Déconnexion/erreur Twitch: %s", e)
with webapp.app_context():
webapp.config["BOT_STATUS"]["twitch_connected"] = False
time.sleep(retry_delay)
# je ne sais pas encore comment appeler ça
async def _close(self): async def _close(self):
self.chat.stop() self.chat.stop()
await self.twitch.close() await self.twitch.close()
twitchBot = TwitchBot()
twitchBot = TwitchBot()
+137
View File
@@ -0,0 +1,137 @@
import logging
from datetime import datetime, timedelta
from twitchAPI.chat import Chat
from twitchAPI.twitch import Twitch
from database import db
from database.models import TwitchAnnouncement
from webapp import webapp
logger = logging.getLogger('twitch-announcements')
logger.setLevel(logging.INFO)
# Délai minimum entre deux annonces (quelle qu'elles soient) pour éviter le spam
MIN_DELAY_BETWEEN_ANNOUNCEMENTS_MINUTES = 5
_message_count: int = 0
_last_announcement_index: int = -1 # Pour la rotation round-robin
def incrementMessageCount():
global _message_count
_message_count += 1
def _getAndResetMessageCount() -> int:
global _message_count
count = _message_count
_message_count = 0
return count
async def _is_channel_live(twitch: Twitch, channel: str) -> bool:
"""Vérifie si la chaîne Twitch est actuellement en live."""
try:
async for _ in twitch.get_streams(user_login=[channel]):
return True
except Exception as e:
logger.warning(f'Impossible de vérifier le statut live de {channel}: {e}')
return False
async def checkAndSendAnnouncements(chat: Chat, channel: str, twitch: Twitch):
"""
Envoie une annonce en rotation parmi celles dont la périodicité est écoulée,
uniquement si la chaîne est en live et qu'il y a assez d'activité dans le chat.
Appelé périodiquement par le bot Twitch.
"""
global _last_announcement_index
with webapp.app_context():
if not await _is_channel_live(twitch, channel):
return
announcements: list[TwitchAnnouncement] = TwitchAnnouncement.query.filter_by(enable=True).order_by(TwitchAnnouncement.id).all()
if not announcements:
return
now = datetime.now()
# Ne pas envoyer si une annonce (quelle qu'elle soit) a été envoyée récemment
last_any = max((a.last_sent for a in announcements if a.last_sent), default=None)
if last_any and (now - last_any) < timedelta(minutes=MIN_DELAY_BETWEEN_ANNOUNCEMENTS_MINUTES):
return
# Filtrer les annonces dont la périodicité est écoulée
due = [a for a in announcements if _shouldSend(a, now)]
if not due:
return
# Rotation round-robin : chercher la prochaine annonce après la dernière envoyée
announcement = _selectNextAnnouncement(due, announcements)
if not announcement:
return
# Vérifier le nombre minimum de messages dans le chat
message_count = _getAndResetMessageCount()
if message_count < announcement.min_chat_messages:
logger.debug(f'Annonce "{announcement.name}" ignorée : seulement {message_count} messages (minimum requis : {announcement.min_chat_messages})')
return
try:
await _sendAnnouncement(chat, channel, announcement)
announcement.last_sent = now
_last_announcement_index = announcements.index(announcement)
db.session.commit()
logger.info(f'Annonce envoyée : {announcement.name} (après {message_count} messages)')
except Exception as e:
logger.error(f'Erreur lors de l\'envoi de l\'annonce "{announcement.name}": {e}')
def _selectNextAnnouncement(due: list[TwitchAnnouncement], all_announcements: list[TwitchAnnouncement]) -> TwitchAnnouncement | None:
"""
Sélectionne la prochaine annonce selon un système de rotation round-robin.
Cherche la première annonce éligible après la dernière envoyée.
"""
global _last_announcement_index
if not due:
return None
# Si c'est la première annonce ou si l'index est invalide, prendre la première de la liste
if _last_announcement_index == -1 or _last_announcement_index >= len(all_announcements):
return due[0]
# Chercher la prochaine annonce éligible après la dernière envoyée
start_index = _last_announcement_index + 1
# Parcourir depuis l'index suivant jusqu'à la fin, puis revenir au début
for i in range(len(all_announcements)):
idx = (start_index + i) % len(all_announcements)
announcement = all_announcements[idx]
if announcement in due:
return announcement
# Fallback : retourner la première annonce éligible
return due[0]
def _shouldSend(announcement: TwitchAnnouncement, now: datetime) -> bool:
"""
Vérifie si une annonce doit être envoyée basée sur sa périodicité.
"""
if announcement.last_sent is None:
return True
time_since_last = now - announcement.last_sent
periodicity_delta = timedelta(minutes=announcement.periodicity)
return time_since_last >= periodicity_delta
async def _sendAnnouncement(chat: Chat, channel: str, announcement: TwitchAnnouncement):
"""
Envoie une annonce dans le chat Twitch.
"""
await chat.send_message(channel, announcement.text)
+264
View File
@@ -0,0 +1,264 @@
# Notifications d'événements Twitch (sub, follow, raid, clip) : chat + Discord
import asyncio
import logging
from typing import Any
import discord
from twitchAPI.chat import Chat
from twitchAPI.eventsub.websocket import EventSubWebsocket
from twitchAPI.object.eventsub import (
ChannelFollowEvent,
ChannelRaidEvent,
ChannelSubscribeEvent,
)
from twitchAPI.twitch import Twitch
from database import db
from database.models import TwitchEventNotification
from webapp import webapp
logger = logging.getLogger("twitch-events")
def _format_message(template: str, **kwargs: Any) -> str:
if not template:
return ""
for k, v in (kwargs or {}).items():
template = template.replace("{" + k + "}", str(v or ""))
return template
async def _send_twitch(chat: Chat, channel: str, text: str) -> None:
if not text or not chat:
return
try:
await chat.send_message(channel, text[:500])
except Exception as e:
logger.error("Envoi chat Twitch événement: %s", e)
def _schedule_discord_send(channel_id: int, content: str | None, embed: discord.Embed | None) -> None:
"""Planifie l'envoi sur le canal Discord (sans bloquer le loop Twitch)."""
from discordbot import bot
try:
ch = bot.get_channel(channel_id)
if not ch:
logger.warning("Canal Discord %s introuvable", channel_id)
return
payload = content if content else embed
if not payload:
return
asyncio.run_coroutine_threadsafe(
ch.send(content=content, embed=embed) if (content and embed) else ch.send(content=content or None, embed=embed if not content else None),
bot.loop,
)
except Exception as e:
logger.error("Envoi Discord événement: %s", e)
async def _handle_follow(data: ChannelFollowEvent, chat: Chat, channel: str) -> None:
with webapp.app_context():
cfg = TwitchEventNotification.query.filter_by(event_type="follow", enable=True).first()
if not cfg:
return
ev = data.event
user = getattr(ev, "user_name", None) or getattr(ev, "user_login", "")
user_login = getattr(ev, "user_login", user)
msg = _format_message(
cfg.message_twitch or "Merci {user} pour le follow !",
user=user_login,
user_name=user,
)
if cfg.notify_twitch_chat and msg:
await _send_twitch(chat, channel, msg)
if cfg.notify_discord and cfg.discord_channel_id:
content = _format_message(cfg.message_discord or "", user=user_login, user_name=user)
try:
embed_color = int(cfg.embed_color or "9146FF", 16)
except ValueError:
embed_color = 0x9146FF
embed = discord.Embed(
title=_format_message(cfg.embed_title or "Nouveau follow", user=user_login, user_name=user),
description=cfg.embed_description or f"{user} suit maintenant la chaîne.",
color=embed_color,
)
_schedule_discord_send(cfg.discord_channel_id, content.strip() or None, embed)
async def _handle_subscribe(data: ChannelSubscribeEvent, chat: Chat, channel: str) -> None:
with webapp.app_context():
cfg = TwitchEventNotification.query.filter_by(event_type="sub", enable=True).first()
if not cfg:
return
ev = data.event
user = getattr(ev, "user_name", None) or getattr(ev, "user_login", "")
user_login = getattr(ev, "user_login", user)
msg = _format_message(
cfg.message_twitch or "Merci {user} pour l'abonnement !",
user=user_login,
user_name=user,
)
if cfg.notify_twitch_chat and msg:
await _send_twitch(chat, channel, msg)
if cfg.notify_discord and cfg.discord_channel_id:
content = _format_message(cfg.message_discord or "", user=user_login, user_name=user)
try:
embed_color = int(cfg.embed_color or "9146FF", 16)
except ValueError:
embed_color = 0x9146FF
embed = discord.Embed(
title=_format_message(cfg.embed_title or "Nouvel abonnement", user=user_login, user_name=user),
description=cfg.embed_description or f"Merci à {user} pour l'abonnement !",
color=embed_color,
)
_schedule_discord_send(cfg.discord_channel_id, content.strip() or None, embed)
async def _handle_raid(data: ChannelRaidEvent, chat: Chat, channel: str) -> None:
with webapp.app_context():
cfg = TwitchEventNotification.query.filter_by(event_type="raid", enable=True).first()
if not cfg:
return
ev = data.event
from_broadcaster = getattr(ev, "from_broadcaster_user_name", None) or getattr(ev, "from_broadcaster_user_login", "")
viewers = getattr(ev, "viewers", 0)
msg = _format_message(
cfg.message_twitch or "Bienvenue aux {viewers} viewers de {from_broadcaster_name} !",
from_broadcaster_name=from_broadcaster,
viewers=viewers,
)
if cfg.notify_twitch_chat and msg:
await _send_twitch(chat, channel, msg)
if cfg.notify_discord and cfg.discord_channel_id:
content = _format_message(
cfg.message_discord or "",
from_broadcaster_name=from_broadcaster,
viewers=viewers,
)
try:
embed_color = int(cfg.embed_color or "9146FF", 16)
except ValueError:
embed_color = 0x9146FF
embed = discord.Embed(
title=_format_message(
cfg.embed_title or "Raid reçu",
from_broadcaster_name=from_broadcaster,
viewers=viewers,
),
description=cfg.embed_description or f"{from_broadcaster} a raid avec {viewers} viewers !",
color=embed_color,
)
_schedule_discord_send(cfg.discord_channel_id, content.strip() or None, embed)
async def notify_clip(
chat: Chat | None,
channel: str,
*,
user: str,
title: str,
url: str,
thumbnail_url: str,
clip_id: str,
) -> None:
"""Appelé quand un nouveau clip est détecté (polling)."""
with webapp.app_context():
cfg = TwitchEventNotification.query.filter_by(event_type="clip", enable=True).first()
if not cfg:
return
msg_twitch = _format_message(
cfg.message_twitch or "Nouveau clip par {user} : {title} {url}",
user=user,
title=title,
url=url,
)
if cfg.notify_twitch_chat and chat and msg_twitch:
await _send_twitch(chat, channel, msg_twitch)
if cfg.notify_discord and cfg.discord_channel_id:
content = _format_message(
cfg.message_discord or "",
user=user,
title=title,
url=url,
thumbnail_url=thumbnail_url or "",
)
try:
embed_color = int(cfg.embed_color or "9146FF", 16)
except ValueError:
embed_color = 0x9146FF
embed = discord.Embed(
title=_format_message(cfg.embed_title or "Nouveau clip", user=user, title=title),
url=url,
description=cfg.embed_description or title,
color=embed_color,
)
if cfg.embed_thumbnail and thumbnail_url:
embed.set_thumbnail(url=thumbnail_url)
_schedule_discord_send(cfg.discord_channel_id, content.strip() or None, embed)
cfg.last_clip_id = clip_id
db.session.commit()
def create_eventsub(twitch: Twitch, callback_loop: asyncio.AbstractEventLoop) -> EventSubWebsocket:
"""Crée et démarre le client EventSub. Les callbacks seront exécutés sur `callback_loop`."""
eventsub = EventSubWebsocket(twitch, callback_loop=callback_loop)
eventsub.start()
return eventsub
async def register_eventsub_handlers(
eventsub: EventSubWebsocket,
broadcaster_id: str,
chat: Chat,
channel: str,
) -> None:
"""Enregistre follow, sub, raid sur l'EventSub. À appeler dans les 10 s après start()."""
# Définir les callbacks comme des wrappers explicites
async def on_follow(data: ChannelFollowEvent) -> None:
try:
await _handle_follow(data, chat, channel)
except Exception as e:
logger.error("Erreur handler follow: %s", e)
async def on_subscribe(data: ChannelSubscribeEvent) -> None:
try:
await _handle_subscribe(data, chat, channel)
except Exception as e:
logger.error("Erreur handler subscribe: %s", e)
async def on_raid(data: ChannelRaidEvent) -> None:
try:
await _handle_raid(data, chat, channel)
except Exception as e:
logger.error("Erreur handler raid: %s", e)
# Chaque souscription est tentée séparément : si le token n'a pas channel:read:subscriptions,
# seule "sub" échouera ; follow et raid restent actifs.
subscriptions_ok = 0
try:
await eventsub.listen_channel_follow_v2(broadcaster_id, broadcaster_id, on_follow)
logger.info("EventSub: follow enregistré ✓")
subscriptions_ok += 1
except Exception as e:
logger.error("EventSub follow: %s", e)
try:
await eventsub.listen_channel_subscribe(broadcaster_id, on_subscribe)
logger.info("EventSub: subscribe enregistré ✓")
subscriptions_ok += 1
except Exception as e:
logger.warning("EventSub subscribe (nécessite scope channel:read:subscriptions): %s", e)
try:
await eventsub.listen_channel_raid(to_broadcaster_user_id=broadcaster_id, callback=on_raid)
logger.info("EventSub: raid enregistré ✓")
subscriptions_ok += 1
except Exception as e:
logger.error("EventSub raid: %s", e)
if subscriptions_ok == 0:
logger.error("EventSub: AUCUNE souscription n'a réussi ! Le WebSocket va se fermer.")
else:
logger.info(f"EventSub: {subscriptions_ok}/3 souscriptions actives")
+166
View File
@@ -0,0 +1,166 @@
import re
import logging
from datetime import datetime, timedelta
from twitchAPI.twitch import Twitch
from twitchAPI.chat import ChatMessage
from database import db
from database.models import TwitchLinkFilter, TwitchAllowedDomain, TwitchPermit, TwitchAllowedUser
from twitchbot.moderation import _log_action, _get_broadcaster_id, _get_moderator_id, _get_user_id, _is_moderator
from webapp import webapp
logger = logging.getLogger('twitch-link-filter')
logger.setLevel(logging.INFO)
URL_REGEX = re.compile(r'https?://[^\s]+|(?:www\.)?[a-zA-Z0-9-]+\.[a-zA-Z]{2,}(?:/[^\s]*)?')
def _get_filter_config():
"""Retourne un dictionnaire avec la config du filtre de liens"""
with webapp.app_context():
config = TwitchLinkFilter.query.first()
if not config:
config = TwitchLinkFilter(enabled=False)
db.session.add(config)
db.session.commit()
# Retourner un dict pour éviter DetachedInstanceError
return {
'enabled': config.enabled,
'allow_subscribers': config.allow_subscribers,
'allow_vips': config.allow_vips,
'allow_moderators': config.allow_moderators,
'timeout_duration': config.timeout_duration,
'warning_message': config.warning_message
}
def _get_allowed_domains():
with webapp.app_context():
return [d.domain.lower() for d in TwitchAllowedDomain.query.all()]
def _is_user_whitelisted(username: str) -> bool:
with webapp.app_context():
return TwitchAllowedUser.query.filter_by(username=username.lower()).first() is not None
def _has_valid_permit(username: str) -> bool:
with webapp.app_context():
permit = TwitchPermit.query.filter_by(username=username.lower()).first()
if permit and permit.expires_at > datetime.now():
return True
if permit and permit.expires_at <= datetime.now():
db.session.delete(permit)
db.session.commit()
return False
def _extract_domain(url: str) -> str:
url = url.lower()
url = re.sub(r'^https?://', '', url)
url = re.sub(r'^www\.', '', url)
return url.split('/')[0]
def _is_domain_allowed(url: str, allowed_domains: list) -> bool:
domain = _extract_domain(url)
for allowed in allowed_domains:
if domain == allowed or domain.endswith('.' + allowed):
return True
return False
async def check_message_for_links(msg: ChatMessage, twitch: Twitch) -> bool:
config = _get_filter_config()
if not config['enabled']:
return True
if config['allow_moderators'] and (msg.user.mod or msg.user.name.lower() == msg.room.name.lower()):
return True
if config['allow_vips'] and msg.user.vip:
return True
if config['allow_subscribers'] and msg.user.subscriber:
return True
if _is_user_whitelisted(msg.user.name):
return True
urls = URL_REGEX.findall(msg.text)
if not urls:
return True
if _has_valid_permit(msg.user.name):
with webapp.app_context():
permit = TwitchPermit.query.filter_by(username=msg.user.name.lower()).first()
if permit:
db.session.delete(permit)
db.session.commit()
return True
allowed_domains = _get_allowed_domains()
for url in urls:
if not _is_domain_allowed(url, allowed_domains):
await _handle_unauthorized_link(msg, twitch, config, url)
return False
return True
async def _handle_unauthorized_link(msg: ChatMessage, twitch: Twitch, config: dict, url: str):
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
user_id = await _get_user_id(twitch, msg.user.name)
if user_id and config['timeout_duration'] > 0:
try:
await twitch.ban_user(broadcaster_id, moderator_id, user_id, reason="Lien non autorise", duration=config['timeout_duration'])
except Exception as e:
logger.error(f"Erreur timeout link filter: {e}")
try:
await twitch.delete_chat_message(broadcaster_id, moderator_id, message_id=msg.id)
except Exception as e:
logger.error(f"Erreur suppression message: {e}")
if config['warning_message']:
await msg.reply(config['warning_message'])
_log_action("link_blocked", "AutoMod", msg.user.name, _extract_domain(url))
logger.info(f"Lien bloque de {msg.user.name}: {url}")
async def permit_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
if len(args) < 1:
await msg.reply("Usage: !permit <viewer> [minutes]")
return
username = args[0].lstrip('@').lower()
duration = 60
if len(args) >= 2:
try:
duration = int(args[1]) * 60
except ValueError:
duration = 60
expires_at = datetime.now() + timedelta(seconds=duration)
with webapp.app_context():
existing = TwitchPermit.query.filter_by(username=username).first()
if existing:
existing.expires_at = expires_at
else:
permit = TwitchPermit(username=username, expires_at=expires_at)
db.session.add(permit)
db.session.commit()
_log_action("permit", msg.user.name, username, f"{duration}s")
await msg.reply(f"@{username} peut poster un lien pendant {duration // 60} minute(s)")
logger.info(f"Permit accorde a {username} par {msg.user.name}")
+212 -13
View File
@@ -1,4 +1,7 @@
import logging import logging
import asyncio
from datetime import datetime
import discord
from twitchAPI.twitch import Twitch from twitchAPI.twitch import Twitch
from twitchAPI.object.api import Stream from twitchAPI.object.api import Stream
@@ -11,39 +14,235 @@ from webapp import webapp
logger = logging.getLogger('live-alert') logger = logging.getLogger('live-alert')
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
_live_alert_first_check = True
def _stream_thumbnail_url(stream: Stream) -> str:
"""URL de la miniature du stream (preview Twitch)."""
url = getattr(stream, 'thumbnail_url', None) or ''
if '{width}' in url or '{height}' in url:
url = url.replace('{width}', '320').replace('{height}', '180')
if not url:
url = f"https://static-cdn.jtvnw.net/previews-ttv/live_user_{stream.user_login}-320x180.jpg"
return url
def _format_embed_text(text: str, stream: Stream, stream_url: str, thumbnail: str) -> str:
"""Formate un texte d'embed avec les variables stream."""
if not text:
return ''
try:
return text.format(
user_login=stream.user_login or '',
user_name=stream.user_name or '',
game_name=getattr(stream, 'game_name', None) or '',
title=stream.title or '',
language=getattr(stream, 'language', None) or '',
stream_url=stream_url,
thumbnail=thumbnail,
)
except KeyError:
return text
async def checkOnlineStreamer(twitch: Twitch) : async def checkOnlineStreamer(twitch: Twitch) :
global _live_alert_first_check
with webapp.app_context() : with webapp.app_context() :
alerts : list[LiveAlert] = LiveAlert.query.all() alerts : list[LiveAlert] = LiveAlert.query.all()
bot_status = webapp.config["BOT_STATUS"]
was_live = bot_status.get("twitch_is_live", False)
try:
streams = await _retreiveStreams(twitch, alerts) streams = await _retreiveStreams(twitch, alerts)
except Exception as e:
logger.error(f'Erreur lors de la récupération des streams, on conserve l\'état actuel : {e}')
return
watch_stream = None
# Récupération du statut du live principal (channel configuré)
from database.helpers import ConfigurationHelper
main_channel = ConfigurationHelper().getValue('twitch_channel')
main_stream = None
if main_channel:
main_stream = next((s for s in streams if s.user_login.lower() == main_channel.lower()), None)
# Mise à jour du BOT_STATUS pour la webapp
if main_stream:
bot_status["twitch_is_live"] = True
bot_status["twitch_viewer_count"] = getattr(main_stream, 'viewer_count', 0)
bot_status["twitch_stream_title"] = getattr(main_stream, 'title', '') or ''
bot_status["twitch_game_name"] = getattr(main_stream, 'game_name', '') or ''
bot_status["twitch_started_at"] = main_stream.started_at.isoformat() if getattr(main_stream, 'started_at', None) else None
bot_status["twitch_ended_at"] = None
bot_status["twitch_chat_clear_notice_sent"] = False
else:
bot_status["twitch_is_live"] = False
bot_status["twitch_viewer_count"] = 0
bot_status["twitch_stream_title"] = ""
bot_status["twitch_game_name"] = ""
bot_status["twitch_started_at"] = None
if was_live and not bot_status.get("twitch_ended_at"):
bot_status["twitch_ended_at"] = datetime.now().isoformat()
if was_live and not bot_status.get("twitch_chat_clear_notice_sent"):
messages = bot_status.setdefault("twitch_chat_messages", [])
now_iso = datetime.now().isoformat()
messages.append({
'username': 'System',
'text': 'Live terminé, ce chat sera vidé dans 1h.',
'timestamp': now_iso,
'is_mod': False,
'is_subscriber': False,
'is_vip': False,
'color': '#22c55e',
'panel_only': True,
})
if len(messages) > 100:
messages.pop(0)
bot_status["twitch_chat_clear_notice_sent"] = True
# Premier check : synchronisation sans notification
if _live_alert_first_check:
logger.info('Live Alert: première vérification, synchronisation sans notification')
for alert in alerts:
stream = next((s for s in streams if s.user_login.lower() == (alert.login or '').lower()), None)
if stream:
alert.online = True
if alert.watch_activity and alert.enable:
watch_stream = stream
else:
alert.online = False
await _updateBotActivity(watch_stream)
db.session.commit()
_live_alert_first_check = False
return
# Vérifications normales ensuite
for alert in alerts : for alert in alerts :
stream = next((s for s in streams if s.user_login == alert.login), None) stream = next((s for s in streams if s.user_login.lower() == (alert.login or '').lower()), None)
if stream : if stream :
logger.info(f'Streamer en ligne : {alert.login}') logger.info(f'Streamer en ligne : {alert.login}')
if not alert.online and alert.enable : if not alert.online and alert.enable :
logger.info(f'N\'etait pas en ligne auparavant : {alert.login}') logger.info(f'N\'etait pas en ligne auparavant : {alert.login}')
await _notifyAlert(alert, stream) await _notifyAlert(alert, stream)
alert.online = True alert.online = True
if alert.watch_activity and alert.enable:
watch_stream = stream
else : else :
logger.info(f'Streamer hors ligne : {alert.login}') logger.info(f'Streamer hors ligne : {alert.login}')
alert.online = False alert.online = False
await _updateBotActivity(watch_stream)
db.session.commit() db.session.commit()
async def _notifyAlert(alert : LiveAlert, stream : Stream):
message : str = alert.message.format(stream)
logger.info(f'Message de notification : {message}')
bot.loop.create_task(_sendMessage(alert.notify_channel, message))
async def _sendMessage(channel : int, message : str) : async def _updateBotActivity(stream: Stream | None):
logger.info(f'Envoi de notification : {message}') if not bot.loop or bot.loop.is_closed():
await bot.get_channel(channel).send(message) logger.warning("Loop Discord non disponible pour mise à jour de présence")
logger.info(f'Notification envoyé') return
async def _retreiveStreams(twitch: Twitch, alerts : list[LiveAlert]) -> list[Stream] : if stream:
streams : list[Stream] = [] logger.info(f'Mise à jour de l\'activité : Regarde le live de {stream.user_name}')
activity = discord.Streaming(
name=f'Regarde le live de {stream.user_name}',
url=f'https://www.twitch.tv/{stream.user_login}'
)
with webapp.app_context():
webapp.config["BOT_STATUS"]["discord_streaming_activity"] = True
future = asyncio.run_coroutine_threadsafe(
bot.change_presence(status=discord.Status.online, activity=activity),
bot.loop
)
future.result(timeout=10)
else:
logger.info('Aucun stream à regarder, retour à l\'activité normale')
with webapp.app_context():
webapp.config["BOT_STATUS"]["discord_streaming_activity"] = False
# Remettre une humeur aléatoire
from database.models import Humeur
import random
humeurs = Humeur.query.all()
if humeurs:
humeur = random.choice(humeurs)
logger.info(f'Réinitialisation du statut : {humeur.text}')
future = asyncio.run_coroutine_threadsafe(
bot.change_presence(status=discord.Status.online, activity=discord.CustomActivity(humeur.text)),
bot.loop
)
future.result(timeout=10)
else:
# Si pas de humeur, remettre un statut par défaut
future = asyncio.run_coroutine_threadsafe(
bot.change_presence(status=discord.Status.online, activity=None),
bot.loop
)
future.result(timeout=10)
async def _notifyAlert(alert: LiveAlert, stream: Stream):
stream_url = f'https://www.twitch.tv/{stream.user_login}'
thumbnail = _stream_thumbnail_url(stream)
# Message texte optionnel (avant l'embed)
message_text = None
if alert.message and alert.message.strip():
try:
message_text = alert.message.format(stream)
except KeyError:
message_text = alert.message
# Construction de l'embed Discord
try:
embed_color = int(alert.embed_color or '9146FF', 16)
except ValueError:
embed_color = 0x9146FF
embed_title = _format_embed_text(alert.embed_title, stream, stream_url, thumbnail) if alert.embed_title else (stream.title or f"{stream.user_name} est en live")
embed_description = _format_embed_text(alert.embed_description, stream, stream_url, thumbnail) if alert.embed_description else None
embed = discord.Embed(
title=embed_title,
url=stream_url,
color=embed_color
)
if embed_description:
embed.description = embed_description
author_name = _format_embed_text(alert.embed_author_name, stream, stream_url, thumbnail) if alert.embed_author_name else stream.user_name
user_id = getattr(stream, 'user_id', None)
author_icon = alert.embed_author_icon or (f"https://static-cdn.jtvnw.net/jtv_user_pictures/{user_id}-profile_image-70x70.png" if user_id else "https://static-cdn.jtvnw.net/ttv-favicon/favicon-32x32.png")
embed.set_author(name=author_name, icon_url=author_icon)
if alert.embed_thumbnail and thumbnail:
embed.set_thumbnail(url=thumbnail)
if alert.embed_image and thumbnail:
embed.set_image(url=thumbnail)
if alert.embed_footer:
footer_text = _format_embed_text(alert.embed_footer, stream, stream_url, thumbnail)
if footer_text:
embed.set_footer(text=footer_text)
logger.info(f'Envoi de notification live (embed) : {stream.user_login}')
bot.loop.create_task(_sendMessage(alert.notify_channel, message_text, embed))
async def _sendMessage(channel_id: int, message: str | None, embed: discord.Embed):
try:
discord_channel = bot.get_channel(channel_id)
if not discord_channel:
logger.error(f"Canal Discord {channel_id} introuvable")
return
if message and message.strip():
await discord_channel.send(content=message, embed=embed)
else:
await discord_channel.send(embed=embed)
logger.info('Notification live envoyée')
except Exception as e:
logger.error(f"Erreur lors de l'envoi de la notification live : {e}")
async def _retreiveStreams(twitch: Twitch, alerts: list[LiveAlert]) -> list[Stream]:
streams: list[Stream] = []
logger.info(f'Recherche de streams pour : {alerts}') logger.info(f'Recherche de streams pour : {alerts}')
async for stream in twitch.get_streams(user_login = [alert.login for alert in alerts]): async for stream in twitch.get_streams(user_login=[alert.login for alert in alerts]):
streams.append(stream) streams.append(stream)
logger.info(f'Ces streams sont en ligne : {streams}') logger.info(f'Ces streams sont en ligne : {streams}')
return streams return streams
+446
View File
@@ -0,0 +1,446 @@
import re
import logging
from datetime import datetime
from twitchAPI.twitch import Twitch
from twitchAPI.chat import ChatMessage
from database import db
from database.models import TwitchAnnouncement, TwitchModerationLog, TwitchBannedWord
from webapp import webapp
logger = logging.getLogger('twitch-moderation')
logger.setLevel(logging.INFO)
last_multitwitch: str = None
games_disabled: bool = False
def _log_action(action: str, moderator: str, target: str = None, details: str = None):
with webapp.app_context():
log = TwitchModerationLog(
action=action,
moderator=moderator,
target=target,
details=details,
created_at=datetime.now()
)
db.session.add(log)
db.session.commit()
def _is_moderator(msg: ChatMessage) -> bool:
return msg.user.mod or msg.user.name.lower() == msg.room.name.lower()
async def _get_broadcaster_id(twitch: Twitch, channel: str) -> str:
async for user in twitch.get_users(logins=[channel]):
return user.id
return None
async def _get_user_id(twitch: Twitch, username: str) -> str:
async for user in twitch.get_users(logins=[username]):
return user.id
return None
async def _get_moderator_id(twitch: Twitch) -> str:
async for user in twitch.get_users():
return user.id
return None
async def timeout_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
if len(args) < 1:
await msg.reply("Usage: !timeout <viewer> [minutes] [raison]")
return
viewer = args[0].lstrip('@')
duration = 180 # 3 minutes par défaut
reason = "Timeout"
# Si args[1] est un nombre, c'est la durée, sinon c'est la raison
if len(args) >= 2:
try:
duration = int(args[1]) * 60
# Tout ce qui suit est la raison
if len(args) >= 3:
reason = ' '.join(args[2:])
except ValueError:
# args[1] n'est pas un nombre, donc tout depuis args[1] est la raison
reason = ' '.join(args[1:])
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
user_id = await _get_user_id(twitch, viewer)
if user_id:
await twitch.ban_user(broadcaster_id, moderator_id, user_id, reason=reason, duration=duration)
# Log avec durée et raison
log_details = f"{duration}s - {reason}"
_log_action("timeout", msg.user.name, viewer, log_details)
logger.info(f'{viewer} timeout pour {duration}s par {msg.user.name} - Raison: {reason}')
async def ban_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
if len(args) < 1:
await msg.reply("Usage: !ban <viewer1> [viewer2] ...")
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
for viewer in args[:5]:
viewer = viewer.lstrip('@')
user_id = await _get_user_id(twitch, viewer)
if user_id:
await twitch.ban_user(broadcaster_id, moderator_id, user_id, reason="Ban")
_log_action("ban", msg.user.name, viewer)
logger.info(f'{viewer} banni par {msg.user.name}')
async def unban_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
if len(args) < 1:
await msg.reply("Usage: !unban <viewer1> [viewer2] ...")
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
for viewer in args[:5]:
viewer = viewer.lstrip('@')
user_id = await _get_user_id(twitch, viewer)
if user_id:
await twitch.unban_user(broadcaster_id, moderator_id, user_id)
_log_action("unban", msg.user.name, viewer)
logger.info(f'{viewer} débanni par {msg.user.name}')
async def clean_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
if len(args) >= 1:
viewer = args[0].lstrip('@')
user_id = await _get_user_id(twitch, viewer)
if user_id:
await twitch.ban_user(broadcaster_id, moderator_id, user_id, reason="Purge messages", duration=1)
_log_action("clean", msg.user.name, viewer)
logger.info(f'Messages de {viewer} supprimés par {msg.user.name}')
else:
await twitch.delete_chat_message(broadcaster_id, moderator_id)
_log_action("clean", msg.user.name, None, "Chat complet")
logger.info(f'Chat nettoyé par {msg.user.name}')
async def shieldmode_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
if len(args) < 1:
await msg.reply("Usage: !shieldmode <on/off>")
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
is_active = args[0].lower() == "on"
await twitch.update_shield_mode_status(broadcaster_id, moderator_id, is_active)
_log_action("shieldmode", msg.user.name, None, "on" if is_active else "off")
logger.info(f'Shield mode {"activé" if is_active else "désactivé"} par {msg.user.name}')
async def settitle_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
parts = msg.text.split(maxsplit=1)
if len(parts) < 2:
await msg.reply("Usage: !settitle <titre>")
return
title = parts[1]
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
await twitch.modify_channel_information(broadcaster_id, title=title)
_log_action("settitle", msg.user.name, None, title)
logger.info(f'Titre changé en "{title}" par {msg.user.name}')
async def setgame_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
parts = msg.text.split(maxsplit=1)
if len(parts) < 2:
await msg.reply("Usage: !setgame <jeu>")
return
game_name = parts[1]
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
game_id = None
async for game in twitch.get_games(names=[game_name]):
game_id = game.id
break
if game_id:
await twitch.modify_channel_information(broadcaster_id, game_id=game_id)
_log_action("setgame", msg.user.name, None, game_name)
logger.info(f'Jeu changé en "{game_name}" par {msg.user.name}')
else:
await msg.reply(f"Jeu '{game_name}' introuvable")
async def subon_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
await twitch.update_chat_settings(broadcaster_id, moderator_id, subscriber_mode=True)
_log_action("subon", msg.user.name)
logger.info(f'Mode abonnés activé par {msg.user.name}')
async def suboff_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
await twitch.update_chat_settings(broadcaster_id, moderator_id, subscriber_mode=False)
_log_action("suboff", msg.user.name)
logger.info(f'Mode abonnés désactivé par {msg.user.name}')
async def follon_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
duration = 10
if len(args) >= 1:
try:
duration = int(args[0])
except ValueError:
duration = 10
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
await twitch.update_chat_settings(broadcaster_id, moderator_id, follower_mode=True, follower_mode_duration=duration)
_log_action("follon", msg.user.name, None, f"{duration}min")
logger.info(f'Mode followers ({duration}min) activé par {msg.user.name}')
async def folloff_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
await twitch.update_chat_settings(broadcaster_id, moderator_id, follower_mode=False)
_log_action("folloff", msg.user.name)
logger.info(f'Mode followers désactivé par {msg.user.name}')
async def emoteon_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
await twitch.update_chat_settings(broadcaster_id, moderator_id, emote_mode=True)
_log_action("emoteon", msg.user.name)
logger.info(f'Mode emote activé par {msg.user.name}')
async def emoteoff_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
await twitch.update_chat_settings(broadcaster_id, moderator_id, emote_mode=False)
_log_action("emoteoff", msg.user.name)
logger.info(f'Mode emote désactivé par {msg.user.name}')
async def multitwitch_command(msg: ChatMessage, twitch: Twitch):
global last_multitwitch
args = msg.text.split()[1:]
if len(args) == 0:
if last_multitwitch:
await msg.reply(last_multitwitch)
return
if not _is_moderator(msg):
return
if args[0].lower() == "reset":
last_multitwitch = None
logger.info(f'MultiTwitch reset par {msg.user.name}')
return
if args[0].lower() == "auto":
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
async for channel in twitch.get_channel_information(broadcaster_id):
title = channel.title
mentions = re.findall(r'@(\w+)', title)
if mentions:
channels = [msg.room.name] + mentions
last_multitwitch = f"https://multitwitch.live/{'/'.join(channels)}"
await msg.reply(last_multitwitch)
return
return
channels = []
for arg in args:
if arg == "@":
channels.append(msg.room.name)
else:
channels.append(arg.lstrip('@'))
last_multitwitch = f"https://multitwitch.live/{'/'.join(channels)}"
await msg.reply(last_multitwitch)
logger.info(f'MultiTwitch créé par {msg.user.name}: {last_multitwitch}')
async def ann_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
if len(args) < 2:
await msg.reply("Usage: !ann <alias> <on/off/toggle>")
return
alias = args[0]
action = args[1].lower()
with webapp.app_context():
announcement = TwitchAnnouncement.query.filter_by(name=alias).first()
if not announcement:
await msg.reply(f"Annonce '{alias}' introuvable")
return
if action == "on":
announcement.enable = True
elif action == "off":
announcement.enable = False
elif action == "toggle":
announcement.enable = not announcement.enable
else:
await msg.reply("Action invalide: on/off/toggle")
return
db.session.commit()
status = "activée" if announcement.enable else "désactivée"
logger.info(f'Annonce {alias} {status} par {msg.user.name}')
await msg.reply(f"Annonce '{alias}' {status}")
async def no_game_command(msg: ChatMessage, twitch: Twitch):
global games_disabled
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
if len(args) < 1:
await msg.reply("Usage: !no_game <on/off>")
return
action = args[0].lower()
if action == "on":
games_disabled = True
logger.info(f'Jeux désactivés par {msg.user.name}')
await msg.reply("Jeux désactivés")
elif action == "off":
games_disabled = False
logger.info(f'Jeux activés par {msg.user.name}')
await msg.reply("Jeux activés")
def are_games_disabled() -> bool:
return games_disabled
async def check_message_for_banned_words(msg: ChatMessage, twitch: Twitch) -> bool:
"""
Vérifie si le message contient des mots interdits.
Retourne True si le message est valide, False s'il doit être bloqué.
"""
# Modérateurs et broadcaster exemptés
if msg.user.mod or msg.user.name.lower() == msg.room.name.lower():
return True
with webapp.app_context():
banned_words = TwitchBannedWord.query.filter_by(enabled=True).all()
if not banned_words:
return True
message_lower = msg.text.lower()
for banned_word_entry in banned_words:
word = banned_word_entry.word.lower()
# Recherche du mot dans le message (mot entier ou partie de mot)
if word in message_lower:
# Bloquer le message
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
user_id = await _get_user_id(twitch, msg.user.name)
# Timeout de l'utilisateur
if user_id and banned_word_entry.timeout_duration > 0:
try:
await twitch.ban_user(
broadcaster_id,
moderator_id,
user_id,
reason=f"Mot interdit: {banned_word_entry.word}",
duration=banned_word_entry.timeout_duration
)
except Exception as e:
logger.error(f"Erreur timeout mot interdit: {e}")
# Suppression du message
try:
await twitch.delete_chat_message(broadcaster_id, moderator_id, message_id=msg.id)
except Exception as e:
logger.error(f"Erreur suppression message mot interdit: {e}")
# Log
_log_action("banned_word", "AutoMod", msg.user.name, f"Mot: {banned_word_entry.word}")
logger.info(f"Mot interdit détecté de {msg.user.name}: {banned_word_entry.word}")
return False
return True
+104
View File
@@ -0,0 +1,104 @@
import asyncio
import logging
import time
from twitchAPI.chat import ChatMessage
from database.helpers import ConfigurationHelper
from protondb import searhProtonDb
from twitchbot import _user_has_twitch_permission
from webapp import webapp
_last_used: float = 0.0
TIER_ICONS = {
'platinum': '✅ Platinum',
'gold': '🥇 Gold',
'silver': '🥈 Silver',
'bronze': '🥉 Bronze',
'borked': '❌ Borked',
'native': '🐧 Native',
}
AC_ICONS = {
'supported': '',
'running': '⚠️',
'broken': '',
'denied': '🚫',
'planned': '📅',
}
def _format_game_response(game: dict) -> str:
name = game.get('name', '?')
tier = (game.get('tier') or '').lower()
tier_label = TIER_ICONS.get(tier, tier.capitalize() if tier else '?')
g_id = game.get('id', '')
parts = [f"[{name}] {tier_label}"]
ac_status = (game.get('anticheat_status') or '').lower()
if ac_status:
ac_icon = AC_ICONS.get(ac_status, '')
acs = game.get('anticheats') or []
ac_list = ', '.join(str(ac) for ac in acs if ac)
ac_part = f"Anti-cheat: {ac_icon} {ac_status.capitalize()}"
if ac_list:
ac_part += f" ({ac_list})"
parts.append(ac_part)
parts.append(f"protondb.com/app/{g_id}")
return ' | '.join(parts)
async def protondb_command(msg: ChatMessage):
global _last_used
with webapp.app_context():
if not ConfigurationHelper().getValue('proton_db_twitch_enable'):
return
permission = ConfigurationHelper().getValue('proton_db_twitch_permission') or 'viewer'
cooldown = int(ConfigurationHelper().getValue('proton_db_twitch_cooldown') or 0)
if not _user_has_twitch_permission(msg, permission):
return
if cooldown > 0:
elapsed = time.time() - _last_used
if elapsed < cooldown:
remaining = int(cooldown - elapsed)
await msg.reply(f"@{msg.user.name} La commande !pdb est en cooldown, réessaie dans {remaining}s.")
return
_last_used = time.time()
text = msg.text
for prefix in ('!protondb', '!pdb'):
if text.lower().startswith(prefix):
text = text[len(prefix):]
break
name = text.strip()
if not name:
await msg.reply(f"@{msg.user.name} Utilisation : !pdb <nom du jeu> Exemple : !pdb Elden Ring")
return
def _search():
with webapp.app_context():
return searhProtonDb(name)
try:
loop = asyncio.get_event_loop()
games = await loop.run_in_executor(None, _search)
except Exception as e:
logging.error(f'Erreur ProtonDB Twitch pour "{name}": {e}')
await msg.reply(f"@{msg.user.name} Erreur lors de la recherche ProtonDB.")
return
if not games:
await msg.reply(f"@{msg.user.name} Aucun jeu trouvé pour \"{name}\" sur Steam.")
return
for game in games[:3]:
response = _format_game_response(game)
if len(response) > 500:
response = response[:497] + '...'
await msg.reply(response)
+62 -1
View File
@@ -1,5 +1,66 @@
import os
from flask import Flask from flask import Flask
from flask_login import LoginManager
webapp = Flask(__name__) webapp = Flask(__name__)
from webapp import commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation # Secret key pour les sessions (Flask-Login)
webapp.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-change-in-production")
# État des bots (mis à jour par les bots, lu par le panneau)
webapp.config["BOT_STATUS"] = {
"discord_connected": False,
"discord_guild_count": 0,
"discord_streaming_activity": False,
"twitch_connected": False,
"twitch_channel_name": None,
"twitch_is_live": False,
"twitch_viewer_count": 0,
"twitch_stream_title": "",
"twitch_game_name": "",
"twitch_started_at": None,
"twitch_ended_at": None,
"twitch_chat_clear_notice_sent": False,
"twitch_msg_per_minute": 0,
"twitch_msg_timestamps": [], # Unix timestamps des 60 dernières secondes
"twitch_chat_messages": [], # Derniers messages du chat (max 100)
"shoutbox_heartbeats": {}, # {"username": datetime} — présence des modos
}
login_manager = LoginManager()
login_manager.init_app(webapp)
login_manager.login_view = "login"
login_manager.login_message = "Veuillez vous connecter pour accéder à cette page."
from database.models import WebappUser
@login_manager.user_loader
def load_user(user_id):
try:
return WebappUser.query.get(int(user_id))
except (ValueError, TypeError):
return None
from webapp import auth, commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation, youtube, announcements, twitch_moderation, link_filter, twitch_events, users, settings, freeloot, patreon
from flask import request, redirect, url_for
from flask_login import current_user
@webapp.context_processor
def inject_user_level():
from flask_login import current_user
from database.helpers import ConfigurationHelper
reg = ConfigurationHelper().getValue("registration_enabled")
registration_enabled = reg not in (None, "", "false", "0", "no", "off")
return {
"current_user_level": current_user.get_level() if current_user.is_authenticated else -1,
"registration_enabled": registration_enabled,
}
@webapp.before_request
def require_login():
"""Redirige vers /login si non authentifié (sauf login, register, static, callback Twitch OAuth)."""
if request.endpoint in (None, "login", "register", "static", "twitchReceiveToken"):
return
if not current_user.is_authenticated:
return redirect(url_for("login", next=request.url))
+84
View File
@@ -0,0 +1,84 @@
from flask import render_template, request, redirect, url_for
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.models import TwitchAnnouncement
@webapp.route("/announcements")
@require_page("announcements")
def openAnnouncements():
announcements = TwitchAnnouncement.query.all()
return render_template("announcements.html", announcements=announcements)
@webapp.route("/announcements/add", methods=['POST'])
@require_page("announcements")
def addAnnouncement():
if not can_write_page("announcements"):
return render_template("403.html"), 403
announcement = TwitchAnnouncement(
enable=True,
name=request.form.get('name'),
text=request.form.get('text'),
periodicity=int(request.form.get('periodicity', 10)),
min_chat_messages=int(request.form.get('min_chat_messages', 0))
)
db.session.add(announcement)
db.session.commit()
return redirect(url_for("openAnnouncements"))
@webapp.route("/announcements/toggle/<int:id>")
@require_page("announcements")
def toggleAnnouncement(id):
if not can_write_page("announcements"):
return render_template("403.html"), 403
announcement = TwitchAnnouncement.query.get_or_404(id)
announcement.enable = not announcement.enable
db.session.commit()
return redirect(url_for("openAnnouncements"))
@webapp.route("/announcements/edit/<int:id>")
@require_page("announcements")
def openEditAnnouncement(id):
announcement = TwitchAnnouncement.query.get_or_404(id)
return render_template("announcements.html", announcement=announcement)
@webapp.route("/announcements/edit/<int:id>", methods=['POST'])
@require_page("announcements")
def submitEditAnnouncement(id):
if not can_write_page("announcements"):
return render_template("403.html"), 403
announcement = TwitchAnnouncement.query.get_or_404(id)
announcement.name = request.form.get('name')
announcement.text = request.form.get('text')
announcement.periodicity = int(request.form.get('periodicity', 10))
announcement.min_chat_messages = int(request.form.get('min_chat_messages', 0))
db.session.commit()
return redirect(url_for("openAnnouncements"))
@webapp.route("/announcements/del/<int:id>")
@require_page("announcements")
def delAnnouncement(id):
if not can_write_page("announcements"):
return render_template("403.html"), 403
announcement = TwitchAnnouncement.query.get_or_404(id)
db.session.delete(announcement)
db.session.commit()
return redirect(url_for("openAnnouncements"))
@webapp.route("/announcements/reset/<int:id>")
@require_page("announcements")
def resetAnnouncement(id):
if not can_write_page("announcements"):
return render_template("403.html"), 403
announcement = TwitchAnnouncement.query.get_or_404(id)
announcement.last_sent = None
db.session.commit()
return redirect(url_for("openAnnouncements"))
+164
View File
@@ -0,0 +1,164 @@
# Authentification webapp : login, register, logout et contrôle d'accès par rôles.
from functools import wraps
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_user, logout_user, login_required, current_user
from werkzeug.security import generate_password_hash, check_password_hash
from database import db
from database.models import WebappUser, ROLE_ORDER, PagePermission
from database.helpers import ConfigurationHelper
from webapp import webapp
def require_roles(allowed_roles: list):
"""Décorateur : exige que l'utilisateur soit authentifié et ait l'un des rôles autorisés."""
def decorator(f):
@wraps(f)
def wrapped(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for("login", next=request.url))
if current_user.role not in allowed_roles:
return render_template("403.html"), 403
return f(*args, **kwargs)
return wrapped
return decorator
def require_role_min(min_role: str):
"""Décorateur : exige que l'utilisateur ait au moins le rôle min_role (niveau en base)."""
def decorator(f):
@wraps(f)
def wrapped(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for("login", next=request.url))
if not current_user.has_role_at_least(min_role):
return render_template("403.html"), 403
return f(*args, **kwargs)
return wrapped
return decorator
def _page_min_level(page_key: str, for_write: bool = False) -> int:
"""Niveau minimum requis pour la page (lecture ou écriture)."""
perm = PagePermission.query.filter_by(page_key=page_key).first()
if not perm:
return 0
if for_write and perm.write_level is not None:
return perm.write_level
return perm.min_level
def require_page(page_key: str):
"""Décorateur : accès en lecture selon les permissions de la page (webapp_page_permission)."""
def decorator(f):
@wraps(f)
def wrapped(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for("login", next=request.url))
min_level = _page_min_level(page_key, for_write=False)
if not current_user.has_level_at_least(min_level):
return render_template("403.html"), 403
return f(*args, **kwargs)
return wrapped
return decorator
def require_page_write(page_key: str):
"""Décorateur : accès en écriture selon les permissions de la page."""
def decorator(f):
@wraps(f)
def wrapped(*args, **kwargs):
if not current_user.is_authenticated:
return redirect(url_for("login", next=request.url))
min_level = _page_min_level(page_key, for_write=True)
if not current_user.has_level_at_least(min_level):
return render_template("403.html"), 403
return f(*args, **kwargs)
return wrapped
return decorator
def can_write_page(page_key: str) -> bool:
"""Retourne True si l'utilisateur connecté a le niveau pour écrire sur cette page."""
if not current_user.is_authenticated:
return False
return current_user.has_level_at_least(_page_min_level(page_key, for_write=True))
@webapp.route("/login", methods=["GET", "POST"])
def login():
if current_user.is_authenticated:
return redirect(url_for("index"))
if request.method == "POST":
identifier = (request.form.get("identifier") or "").strip()
password = request.form.get("password") or ""
if not identifier or not password:
flash("Identifiant et mot de passe requis.", "error")
return render_template("login.html")
user = WebappUser.query.filter(
(WebappUser.username == identifier) | (WebappUser.email == identifier)
).first()
if user and check_password_hash(user.password_hash, password):
login_user(user, remember=True)
next_url = request.args.get("next")
if next_url and next_url.startswith("/"):
return redirect(next_url)
return redirect(url_for("index"))
flash("Identifiant ou mot de passe incorrect.", "error")
return render_template("login.html")
return render_template("login.html")
@webapp.route("/register", methods=["GET", "POST"])
def register():
if current_user.is_authenticated:
return redirect(url_for("index"))
# Inscriptions désactivées par le super admin
reg_enabled = ConfigurationHelper().getValue("registration_enabled")
if reg_enabled in (None, "", "false", "0", "no", "off"):
flash("Les inscriptions sont désactivées.", "error")
return redirect(url_for("login"))
if request.method == "POST":
username = (request.form.get("username") or "").strip()
email = (request.form.get("email") or "").strip().lower()
password = request.form.get("password") or ""
password_confirm = request.form.get("password_confirm") or ""
errors = []
if len(username) < 3:
errors.append("Le nom d'utilisateur doit faire au moins 3 caractères.")
if len(email) < 5 or "@" not in email:
errors.append("Adresse e-mail invalide.")
if len(password) < 8:
errors.append("Le mot de passe doit faire au moins 8 caractères.")
if password != password_confirm:
errors.append("Les mots de passe ne correspondent pas.")
if WebappUser.query.filter_by(username=username).first():
errors.append("Ce nom d'utilisateur est déjà pris.")
if WebappUser.query.filter_by(email=email).first():
errors.append("Cette adresse e-mail est déjà utilisée.")
if errors:
for msg in errors:
flash(msg, "error")
return render_template("register.html")
# Premier inscrit = super administrateur
role = "super_administrateur" if WebappUser.query.count() == 0 else "viewer_twitch"
user = WebappUser(
username=username,
email=email,
password_hash=generate_password_hash(password, method="scrypt"),
role=role,
)
db.session.add(user)
db.session.commit()
flash("Compte créé. Vous pouvez vous connecter.", "success")
return redirect(url_for("login"))
return render_template("register.html")
@webapp.route("/logout")
@login_required
def logout():
logout_user()
return redirect(url_for("login"))
+22 -2
View File
@@ -1,19 +1,30 @@
from flask import render_template, request, redirect, url_for, flash from flask import render_template, request, redirect, url_for, flash
from webapp import webapp from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db from database import db
from database.models import Commande from database.models import Commande
@webapp.route("/commandes") @webapp.route("/commandes")
@require_page("commandes")
def commandes(): def commandes():
commandes_list = Commande.query.all() commandes_list = Commande.query.all()
return render_template("commandes.html", commandes=commandes_list) return render_template("commandes.html", commandes=commandes_list, twitch_permissions=TWITCH_PERMISSIONS)
TWITCH_PERMISSIONS = {'viewer': 'Tous (viewers)', 'sub': 'Abonnés', 'vip': 'VIP', 'moderator': 'Modérateur'}
@webapp.route("/commandes/add", methods=['POST']) @webapp.route("/commandes/add", methods=['POST'])
@require_page("commandes")
def add_commande(): def add_commande():
if not can_write_page("commandes"):
return render_template("403.html"), 403
trigger = request.form.get('trigger') trigger = request.form.get('trigger')
response = request.form.get('response') response = request.form.get('response')
discord_enable = request.form.get('discord_enable') != None discord_enable = request.form.get('discord_enable') != None
twitch_enable = request.form.get('twitch_enable') != None twitch_enable = request.form.get('twitch_enable') != None
twitch_permission = request.form.get('twitch_permission') or 'viewer'
if twitch_permission not in TWITCH_PERMISSIONS:
twitch_permission = 'viewer'
if trigger and response: if trigger and response:
if not trigger.startswith('!'): if not trigger.startswith('!'):
@@ -21,28 +32,37 @@ def add_commande():
existing = Commande.query.filter_by(trigger=trigger).first() existing = Commande.query.filter_by(trigger=trigger).first()
if not existing: if not existing:
commande = Commande(trigger=trigger, response=response, discord_enable=discord_enable, twitch_enable=twitch_enable) commande = Commande(trigger=trigger, response=response, discord_enable=discord_enable, twitch_enable=twitch_enable, twitch_permission=twitch_permission)
db.session.add(commande) db.session.add(commande)
db.session.commit() db.session.commit()
return redirect(url_for('commandes')) return redirect(url_for('commandes'))
@webapp.route("/commandes/delete/<int:commande_id>") @webapp.route("/commandes/delete/<int:commande_id>")
@require_page("commandes")
def delete_commande(commande_id): def delete_commande(commande_id):
if not can_write_page("commandes"):
return render_template("403.html"), 403
commande = Commande.query.get_or_404(commande_id) commande = Commande.query.get_or_404(commande_id)
db.session.delete(commande) db.session.delete(commande)
db.session.commit() db.session.commit()
return redirect(url_for('commandes')) return redirect(url_for('commandes'))
@webapp.route("/commandes/toggle-discord/<int:commande_id>") @webapp.route("/commandes/toggle-discord/<int:commande_id>")
@require_page("commandes")
def toggle_discord_commande(commande_id): def toggle_discord_commande(commande_id):
if not can_write_page("commandes"):
return render_template("403.html"), 403
commande = Commande.query.get_or_404(commande_id) commande = Commande.query.get_or_404(commande_id)
commande.discord_enable = not commande.discord_enable commande.discord_enable = not commande.discord_enable
db.session.commit() db.session.commit()
return redirect(url_for('commandes')) return redirect(url_for('commandes'))
@webapp.route("/commandes/toggle-twitch/<int:commande_id>") @webapp.route("/commandes/toggle-twitch/<int:commande_id>")
@require_page("commandes")
def toggle_twitch_commande(commande_id): def toggle_twitch_commande(commande_id):
if not can_write_page("commandes"):
return render_template("403.html"), 403
commande = Commande.query.get_or_404(commande_id) commande = Commande.query.get_or_404(commande_id)
commande.twitch_enable = not commande.twitch_enable commande.twitch_enable = not commande.twitch_enable
db.session.commit() db.session.commit()
+66 -4
View File
@@ -1,33 +1,80 @@
from flask import render_template, request, redirect, url_for import asyncio
import logging
from flask import render_template, request, redirect, url_for, flash
from webapp import webapp from webapp import webapp
from webapp.auth import require_page
from database import db from database import db
from database.helpers import ConfigurationHelper from database.helpers import ConfigurationHelper
from discordbot import bot from discordbot import bot
RULES_FORM_KEYS = frozenset({
'rules_channel_id',
'rules_arrival_role_id',
'rules_validated_role_id',
'rules_presentation_channel_id',
'rules_embed_title',
'rules_embed_body',
'rules_button_label',
})
SKIP_FORM_KEYS = frozenset({
'moderation_staff_role_ids',
'rules_ack_section_in_form',
'moderation_roles_in_form',
})
def _form_int_str(raw: str | None) -> str:
s = (raw or '').strip()
return s if s.isdigit() else '0'
@webapp.route("/configurations") @webapp.route("/configurations")
@require_page("configurations")
def openConfigurations(): def openConfigurations():
return render_template("configurations.html", configuration = ConfigurationHelper(), channels = bot.getAllTextChannel(), roles = bot.getAllRoles()) return render_template("configurations.html", configuration=ConfigurationHelper(), channels=bot.getAllTextChannel(), voice_channels=bot.getAllVoiceChannels(), roles=bot.getAllRoles())
@webapp.route("/configurations/update", methods=['POST']) @webapp.route("/configurations/update", methods=['POST'])
@require_page("configurations")
def updateConfiguration(): def updateConfiguration():
checkboxes = { checkboxes = {
'humble_bundle_enable': 'humble_bundle_channel', 'humble_bundle_enable': 'humble_bundle_channel',
'proton_db_enable_enable': 'proton_db_api_id', 'proton_db_enable_enable': 'proton_db_api_id',
'proton_db_twitch_enable': 'proton_db_api_id',
'moderation_enable': 'moderation_staff_role_ids', 'moderation_enable': 'moderation_staff_role_ids',
'moderation_ban_enable': 'moderation_staff_role_ids', 'moderation_ban_enable': 'moderation_staff_role_ids',
'moderation_kick_enable': 'moderation_staff_role_ids', 'moderation_kick_enable': 'moderation_staff_role_ids',
'welcome_enable': 'welcome_channel_id', 'welcome_enable': 'welcome_channel_id',
'leave_enable': 'leave_channel_id' 'leave_enable': 'leave_channel_id',
'auto_rooms_enable': 'auto_rooms_channel_id',
'twitch_commands_enable': 'twitch_channel',
'rules_ack_enable': 'rules_channel_id',
} }
# Ne mettre à jour les rôles staff que si la liste a été rendue dans le formulaire.
# Sinon (bot pas encore prêt, guilds vides), getlist est vide et on écrasait la config en base.
staff_roles = request.form.getlist('moderation_staff_role_ids') staff_roles = request.form.getlist('moderation_staff_role_ids')
if request.form.get('moderation_roles_in_form'):
if staff_roles: if staff_roles:
ConfigurationHelper().createOrUpdate('moderation_staff_role_ids', ','.join(staff_roles)) ConfigurationHelper().createOrUpdate('moderation_staff_role_ids', ','.join(staff_roles))
else: else:
ConfigurationHelper().createOrUpdate('moderation_staff_role_ids', '') ConfigurationHelper().createOrUpdate('moderation_staff_role_ids', '')
if request.form.get('rules_ack_section_in_form'):
ch = ConfigurationHelper()
ch.createOrUpdate('rules_channel_id', _form_int_str(request.form.get('rules_channel_id')))
ch.createOrUpdate('rules_arrival_role_id', _form_int_str(request.form.get('rules_arrival_role_id')))
ch.createOrUpdate('rules_validated_role_id', _form_int_str(request.form.get('rules_validated_role_id')))
ch.createOrUpdate('rules_presentation_channel_id', _form_int_str(request.form.get('rules_presentation_channel_id')))
ch.createOrUpdate('rules_embed_title', (request.form.get('rules_embed_title') or '').strip())
ch.createOrUpdate('rules_embed_body', request.form.get('rules_embed_body') or '')
ch.createOrUpdate('rules_button_label', (request.form.get('rules_button_label') or '').strip())
for key in request.form: for key in request.form:
if key == 'moderation_staff_role_ids': if key in SKIP_FORM_KEYS:
continue
if request.form.get('rules_ack_section_in_form') and key in RULES_FORM_KEYS:
continue continue
value = request.form.get(key) value = request.form.get(key)
if value and value.strip(): if value and value.strip():
@@ -40,3 +87,18 @@ def updateConfiguration():
db.session.commit() db.session.commit()
return redirect(request.referrer) return redirect(request.referrer)
@webapp.route("/configurations/publish-rules", methods=['POST'])
@require_page("configurations")
def publishRulesMessage():
from discordbot.rules_ack import publish_rules_embed_sync
if not bot.loop or bot.loop.is_closed():
flash("Le bot Discord n'est pas connecté.", "error")
return redirect(url_for("openConfigurations"))
ok, msg = publish_rules_embed_sync(bot)
flash(msg, "success" if ok else "error")
if not ok:
logging.warning("publishRulesMessage: %s", msg)
return redirect(url_for("openConfigurations"))
+110
View File
@@ -0,0 +1,110 @@
# Page webapp : configuration des notifications FreeLoot (feed LootScraper)
from flask import render_template, request, redirect, url_for
from urllib.parse import urlencode
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.helpers import ConfigurationHelper
from discordbot import bot
from discordbot.freeloot import send_entry_to_discord_sync
from freeloot_feed import SOURCES, get_display_entries
def _format_updated(updated: str | None) -> str:
"""Formate la date ISO en affichage court."""
if not updated:
return ""
try:
from datetime import datetime
dt = datetime.fromisoformat(updated.replace("Z", "+00:00"))
return dt.strftime("%d/%m/%Y %H:%M")
except Exception:
return updated[:16] if len(updated or "") >= 16 else (updated or "")
def _parse_mention_config(raw: str | None) -> tuple[bool, bool, list[str]]:
"""Retourne (everyone, here, list of role_ids) depuis freeloot_mention."""
everyone, here, role_ids = False, False, []
if not raw or not str(raw).strip():
return (everyone, here, role_ids)
for part in str(raw).strip().split(","):
part = part.strip()
if part == "everyone":
everyone = True
elif part == "here":
here = True
elif part.isdigit():
role_ids.append(part)
return (everyone, here, role_ids)
@webapp.route("/freeloot")
@require_page("freeloot")
def openFreeLoot():
helper = ConfigurationHelper()
channels = bot.getAllTextChannel()
roles = bot.getAllRoles()
raw_sources = helper.getValue("freeloot_sources")
enabled_sources = []
if raw_sources and str(raw_sources).strip():
enabled_sources = [s.strip() for s in str(raw_sources).split(",") if s.strip()]
raw_mention = helper.getValue("freeloot_mention")
mention_everyone, mention_here, mention_role_ids = _parse_mention_config(raw_mention)
entries = get_display_entries()
if enabled_sources:
entries = [e for e in entries if e.get("source_key") in enabled_sources]
for e in entries:
e["updated_formatted"] = _format_updated(e.get("updated"))
return render_template(
"freeloot.html",
configuration=helper,
channels=channels,
roles=roles,
sources=SOURCES,
enabled_sources=enabled_sources,
mention_everyone=mention_everyone,
mention_here=mention_here,
mention_role_ids=mention_role_ids,
entries=entries,
)
@webapp.route("/freeloot/update", methods=["POST"])
@require_page("freeloot")
def updateFreeLoot():
if not can_write_page("freeloot"):
return render_template("403.html"), 403
helper = ConfigurationHelper()
enable = request.form.get("freeloot_enable") in ("on", "1", "true", "yes")
channel_id = request.form.get("freeloot_channel_id")
source_keys = request.form.getlist("freeloot_sources")
mention_parts = []
if request.form.get("freeloot_mention_everyone"):
mention_parts.append("everyone")
if request.form.get("freeloot_mention_here"):
mention_parts.append("here")
mention_parts.extend(request.form.getlist("freeloot_mention_roles"))
helper.createOrUpdate("freeloot_enable", "true" if enable else "false")
if channel_id:
try:
helper.createOrUpdate("freeloot_channel_id", str(int(channel_id)))
except ValueError:
pass
helper.createOrUpdate("freeloot_sources", ",".join(source_keys) if source_keys else "")
helper.createOrUpdate("freeloot_mention", ",".join(mention_parts))
db.session.commit()
return redirect(url_for("openFreeLoot") + "?msg=Configuration enregistrée.&type=success")
@webapp.route("/freeloot/send", methods=["POST"])
@require_page("freeloot")
def send_free_loot_to_discord():
if not can_write_page("freeloot"):
return render_template("403.html"), 403
entry_id = (request.form.get("entry_id") or "").strip()
if not entry_id:
return redirect(url_for("openFreeLoot") + "?" + urlencode({"msg": "Entrée manquante.", "type": "error"}))
ok, message = send_entry_to_discord_sync(bot, entry_id)
msg_type = "success" if ok else "error"
return redirect(url_for("openFreeLoot") + "?" + urlencode({"msg": message, "type": msg_type}))
+9 -1
View File
@@ -1,22 +1,30 @@
from flask import render_template, request, redirect, url_for from flask import render_template, request, redirect, url_for
from webapp import webapp from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db from database import db
from database.models import Humeur from database.models import Humeur
@webapp.route("/humeurs") @webapp.route("/humeurs")
@require_page("humeurs")
def listHumeurs(): def listHumeurs():
humeurs = Humeur.query.all() humeurs = Humeur.query.all()
return render_template("humeurs.html", humeurs = humeurs) return render_template("humeurs.html", humeurs=humeurs)
@webapp.route('/humeurs/add', methods=['POST']) @webapp.route('/humeurs/add', methods=['POST'])
@require_page("humeurs")
def addHumeur(): def addHumeur():
if not can_write_page("humeurs"):
return render_template("403.html"), 403
humeur = Humeur(text=request.form['text']) humeur = Humeur(text=request.form['text'])
db.session.add(humeur) db.session.add(humeur)
db.session.commit() db.session.commit()
return redirect(url_for('listHumeurs')) return redirect(url_for('listHumeurs'))
@webapp.route('/humeurs/del/<id>') @webapp.route('/humeurs/del/<id>')
@require_page("humeurs")
def delHumeur(id): def delHumeur(id):
if not can_write_page("humeurs"):
return render_template("403.html"), 403
Humeur.query.filter_by(id=id).delete() Humeur.query.filter_by(id=id).delete()
db.session.commit() db.session.commit()
return redirect(url_for('listHumeurs')) return redirect(url_for('listHumeurs'))
+17 -1
View File
@@ -1,6 +1,22 @@
from flask import render_template from flask import render_template
from webapp import webapp from webapp import webapp
from webapp.auth import require_page
from database.models import ModerationEvent, TwitchAnnouncement, TwitchModerationLog
@webapp.route("/") @webapp.route("/")
@require_page("index")
def index(): def index():
return render_template("index.html") status = webapp.config["BOT_STATUS"]
sanctions_count = ModerationEvent.query.count()
twitch_announcements_count = TwitchAnnouncement.query.count()
twitch_moderation_count = TwitchModerationLog.query.count()
return render_template(
"index.html",
discord_connected=status["discord_connected"],
discord_guild_count=status["discord_guild_count"],
sanctions_count=sanctions_count,
twitch_connected=status["twitch_connected"],
twitch_channel_name=status["twitch_channel_name"],
twitch_announcements_count=twitch_announcements_count,
twitch_moderation_count=twitch_moderation_count,
)
+103
View File
@@ -0,0 +1,103 @@
from flask import render_template, request, redirect, url_for
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.models import TwitchLinkFilter, TwitchAllowedDomain, TwitchAllowedUser
def _get_or_create_config():
config = TwitchLinkFilter.query.first()
if not config:
config = TwitchLinkFilter(enabled=False)
db.session.add(config)
db.session.commit()
return config
@webapp.route("/link-filter")
@require_page("link_filter")
def link_filter():
config = _get_or_create_config()
domains = TwitchAllowedDomain.query.order_by(TwitchAllowedDomain.domain).all()
users = TwitchAllowedUser.query.order_by(TwitchAllowedUser.username).all()
return render_template("link-filter.html", config=config, domains=domains, users=users)
@webapp.route("/link-filter/toggle")
@require_page("link_filter")
def toggle_link_filter():
if not can_write_page("link_filter"):
return render_template("403.html"), 403
config = _get_or_create_config()
config.enabled = not config.enabled
db.session.commit()
return redirect(url_for('link_filter'))
@webapp.route("/link-filter/update", methods=['POST'])
@require_page("link_filter")
def update_link_filter():
if not can_write_page("link_filter"):
return render_template("403.html"), 403
config = _get_or_create_config()
config.allow_subscribers = request.form.get('allow_subscribers') is not None
config.allow_vips = request.form.get('allow_vips') is not None
config.allow_moderators = request.form.get('allow_moderators') is not None
config.timeout_duration = int(request.form.get('timeout_duration', 60))
config.warning_message = request.form.get('warning_message', '')
db.session.commit()
return redirect(url_for('link_filter'))
@webapp.route("/link-filter/domain/add", methods=['POST'])
@require_page("link_filter")
def add_allowed_domain():
if not can_write_page("link_filter"):
return render_template("403.html"), 403
domain = request.form.get('domain', '').strip().lower()
if domain:
domain = domain.replace('https://', '').replace('http://', '').replace('www.', '')
domain = domain.split('/')[0]
existing = TwitchAllowedDomain.query.filter_by(domain=domain).first()
if not existing:
new_domain = TwitchAllowedDomain(domain=domain)
db.session.add(new_domain)
db.session.commit()
return redirect(url_for('link_filter'))
@webapp.route("/link-filter/domain/delete/<int:domain_id>")
@require_page("link_filter")
def delete_allowed_domain(domain_id):
if not can_write_page("link_filter"):
return render_template("403.html"), 403
domain = TwitchAllowedDomain.query.get_or_404(domain_id)
db.session.delete(domain)
db.session.commit()
return redirect(url_for('link_filter'))
@webapp.route("/link-filter/user/add", methods=['POST'])
@require_page("link_filter")
def add_allowed_user():
if not can_write_page("link_filter"):
return render_template("403.html"), 403
username = request.form.get('username', '').strip().lower().lstrip('@')
if username:
existing = TwitchAllowedUser.query.filter_by(username=username).first()
if not existing:
new_user = TwitchAllowedUser(username=username)
db.session.add(new_user)
db.session.commit()
return redirect(url_for('link_filter'))
@webapp.route("/link-filter/user/delete/<int:user_id>")
@require_page("link_filter")
def delete_allowed_user(user_id):
if not can_write_page("link_filter"):
return render_template("403.html"), 403
user = TwitchAllowedUser.query.get_or_404(user_id)
db.session.delete(user)
db.session.commit()
return redirect(url_for('link_filter'))
+57 -3
View File
@@ -1,12 +1,14 @@
from flask import render_template, request, redirect, url_for from flask import render_template, request, redirect, url_for
from webapp import webapp from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db from database import db
from database.models import LiveAlert from database.models import LiveAlert
from discordbot import bot from discordbot import bot
@webapp.route("/live-alert") @webapp.route("/live-alert")
@require_page("live_alert")
def openLiveAlert(): def openLiveAlert():
alerts : list[LiveAlert] = LiveAlert.query.all() alerts : list[LiveAlert] = LiveAlert.query.all()
channels = bot.getAllTextChannel() channels = bot.getAllTextChannel()
@@ -17,37 +19,89 @@ def openLiveAlert():
return render_template("live-alert.html", alerts = alerts, channels = channels) return render_template("live-alert.html", alerts = alerts, channels = channels)
@webapp.route("/live-alert/add", methods=['POST']) @webapp.route("/live-alert/add", methods=['POST'])
@require_page("live_alert")
def addLiveAlert(): def addLiveAlert():
alert = LiveAlert(enable = True, login = request.form.get('login'), notify_channel = request.form.get('notify_channel'), message = request.form.get('message')) if not can_write_page("live_alert"):
return render_template("403.html"), 403
embed_color = (request.form.get('embed_color') or '9146FF').strip().lstrip('#')
if len(embed_color) != 6:
embed_color = '9146FF'
alert = LiveAlert(
enable=True,
login=request.form.get('login'),
notify_channel=request.form.get('notify_channel'),
message=(request.form.get('message') or '').strip(),
watch_activity=request.form.get('watch_activity') == '1',
embed_title=request.form.get('embed_title') or None,
embed_description=request.form.get('embed_description') or None,
embed_color=embed_color,
embed_footer=request.form.get('embed_footer') or None,
embed_author_name=request.form.get('embed_author_name') or None,
embed_author_icon=request.form.get('embed_author_icon') or None,
embed_thumbnail=request.form.get('embed_thumbnail') == 'on',
embed_image=request.form.get('embed_image') == 'on',
)
db.session.add(alert) db.session.add(alert)
db.session.commit() db.session.commit()
return redirect(url_for("openLiveAlert")) return redirect(url_for("openLiveAlert"))
@webapp.route("/live-alert/toggle/<int:id>") @webapp.route("/live-alert/toggle/<int:id>")
@require_page("live_alert")
def toggleLiveAlert(id): def toggleLiveAlert(id):
if not can_write_page("live_alert"):
return render_template("403.html"), 403
alert : LiveAlert = LiveAlert.query.get_or_404(id) alert : LiveAlert = LiveAlert.query.get_or_404(id)
alert.enable = not alert.enable alert.enable = not alert.enable
db.session.commit() db.session.commit()
return redirect(url_for("openLiveAlert")) return redirect(url_for("openLiveAlert"))
@webapp.route("/live-alert/edit/<int:id>") @webapp.route("/live-alert/edit/<int:id>")
@require_page("live_alert")
def openEditLiveAlert(id): def openEditLiveAlert(id):
alert = LiveAlert.query.get_or_404(id) alert = LiveAlert.query.get_or_404(id)
channels = bot.getAllTextChannel() channels = bot.getAllTextChannel()
return render_template("live-alert.html", alert = alert, channels = channels) return render_template("live-alert.html", alert = alert, channels = channels)
@webapp.route("/live-alert/edit/<int:id>", methods=['POST']) @webapp.route("/live-alert/edit/<int:id>", methods=['POST'])
@require_page("live_alert")
def submitEditLiveAlert(id): def submitEditLiveAlert(id):
alert : LiveAlert = LiveAlert.query.get_or_404(id) if not can_write_page("live_alert"):
return render_template("403.html"), 403
alert: LiveAlert = LiveAlert.query.get_or_404(id)
embed_color = (request.form.get('embed_color') or '9146FF').strip().lstrip('#')
if len(embed_color) != 6:
embed_color = '9146FF'
alert.login = request.form.get('login') alert.login = request.form.get('login')
alert.notify_channel = request.form.get('notify_channel') alert.notify_channel = request.form.get('notify_channel')
alert.message = request.form.get('message') alert.message = (request.form.get('message') or '').strip()
alert.watch_activity = request.form.get('watch_activity') == '1'
alert.embed_title = request.form.get('embed_title') or None
alert.embed_description = request.form.get('embed_description') or None
alert.embed_color = embed_color
alert.embed_footer = request.form.get('embed_footer') or None
alert.embed_author_name = request.form.get('embed_author_name') or None
alert.embed_author_icon = request.form.get('embed_author_icon') or None
alert.embed_thumbnail = request.form.get('embed_thumbnail') == 'on'
alert.embed_image = request.form.get('embed_image') == 'on'
db.session.commit()
return redirect(url_for("openLiveAlert"))
@webapp.route("/live-alert/toggle-watch/<int:id>")
@require_page("live_alert")
def toggleWatchActivity(id):
if not can_write_page("live_alert"):
return render_template("403.html"), 403
alert : LiveAlert = LiveAlert.query.get_or_404(id)
alert.watch_activity = not alert.watch_activity
db.session.commit() db.session.commit()
return redirect(url_for("openLiveAlert")) return redirect(url_for("openLiveAlert"))
@webapp.route("/live-alert/del/<int:id>") @webapp.route("/live-alert/del/<int:id>")
@require_page("live_alert")
def delLiveAlert(id): def delLiveAlert(id):
if not can_write_page("live_alert"):
return render_template("403.html"), 403
alert = LiveAlert.query.get_or_404(id) alert = LiveAlert.query.get_or_404(id)
db.session.delete(alert) db.session.delete(alert)
db.session.commit() db.session.commit()
+53 -2
View File
@@ -1,28 +1,79 @@
from flask import render_template, request, redirect, url_for from flask import render_template, request, redirect, url_for
from webapp import webapp from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db from database import db
from database.models import ModerationEvent from database.models import ModerationEvent
def _top_sanctioned():
return (
db.session.query(
ModerationEvent.discord_id,
db.func.max(ModerationEvent.username).label("username"),
db.func.count(ModerationEvent.id).label("count"),
)
.group_by(ModerationEvent.discord_id)
.order_by(db.func.count(ModerationEvent.id).desc())
.limit(3)
.all()
)
def _top_moderators():
return (
db.session.query(
ModerationEvent.staff_id,
db.func.max(ModerationEvent.staff_name).label("staff_name"),
db.func.count(ModerationEvent.id).label("count"),
)
.group_by(ModerationEvent.staff_id)
.order_by(db.func.count(ModerationEvent.id).desc())
.limit(3)
.all()
)
@webapp.route("/moderation") @webapp.route("/moderation")
@require_page("moderation")
def moderation(): def moderation():
events = ModerationEvent.query.order_by(ModerationEvent.created_at.desc()).all() events = ModerationEvent.query.order_by(ModerationEvent.created_at.desc()).all()
return render_template("moderation.html", events=events, event=None) top_sanctioned = _top_sanctioned()
top_moderators = _top_moderators()
return render_template(
"moderation.html",
events=events,
event=None,
top_sanctioned=top_sanctioned,
top_moderators=top_moderators,
)
@webapp.route("/moderation/edit/<int:event_id>") @webapp.route("/moderation/edit/<int:event_id>")
@require_page("moderation")
def open_edit_moderation_event(event_id): def open_edit_moderation_event(event_id):
event = ModerationEvent.query.get_or_404(event_id) event = ModerationEvent.query.get_or_404(event_id)
events = ModerationEvent.query.order_by(ModerationEvent.created_at.desc()).all() events = ModerationEvent.query.order_by(ModerationEvent.created_at.desc()).all()
return render_template("moderation.html", events=events, event=event) top_sanctioned = _top_sanctioned()
top_moderators = _top_moderators()
return render_template(
"moderation.html",
events=events,
event=event,
top_sanctioned=top_sanctioned,
top_moderators=top_moderators,
)
@webapp.route("/moderation/update/<int:event_id>", methods=['POST']) @webapp.route("/moderation/update/<int:event_id>", methods=['POST'])
@require_page("moderation")
def update_moderation_event(event_id): def update_moderation_event(event_id):
if not can_write_page("moderation"):
return render_template("403.html"), 403
event = ModerationEvent.query.get_or_404(event_id) event = ModerationEvent.query.get_or_404(event_id)
event.reason = request.form.get('reason') event.reason = request.form.get('reason')
db.session.commit() db.session.commit()
return redirect(url_for('moderation')) return redirect(url_for('moderation'))
@webapp.route("/moderation/delete/<int:event_id>") @webapp.route("/moderation/delete/<int:event_id>")
@require_page("moderation")
def delete_moderation_event(event_id): def delete_moderation_event(event_id):
if not can_write_page("moderation"):
return render_template("403.html"), 403
event = ModerationEvent.query.get_or_404(event_id) event = ModerationEvent.query.get_or_404(event_id)
db.session.delete(event) db.session.delete(event)
db.session.commit() db.session.commit()
+103
View File
@@ -0,0 +1,103 @@
from flask import render_template, request, redirect, url_for
from urllib.parse import urlencode
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.helpers import ConfigurationHelper
from database.models import PatreonPost
from discordbot import bot
from discordbot.patreon import send_post_to_discord_sync
def _parse_mention_config(raw: str | None) -> tuple[bool, bool, list[str]]:
everyone, here, role_ids = False, False, []
if not raw or not str(raw).strip():
return (everyone, here, role_ids)
for part in str(raw).strip().split(","):
part = part.strip()
if part == "everyone":
everyone = True
elif part == "here":
here = True
elif part.isdigit():
role_ids.append(part)
return (everyone, here, role_ids)
def _format_pub_date(raw: str | None) -> str:
if not raw or not str(raw).strip():
return ""
try:
from email.utils import parsedate_to_datetime
dt = parsedate_to_datetime(raw)
return dt.strftime("%d/%m/%Y %H:%M")
except Exception:
return raw[:16] if len(raw or "") >= 16 else (raw or "")
@webapp.route("/patreon")
@require_page("patreon")
def openPatreon():
helper = ConfigurationHelper()
channels = bot.getAllTextChannel()
roles = bot.getAllRoles()
raw_mention = helper.getValue("patreon_mention")
mention_everyone, mention_here, mention_role_ids = _parse_mention_config(raw_mention)
posts = PatreonPost.query.order_by(PatreonPost.published_at.desc()).all()
for p in posts:
p.published_formatted = _format_pub_date(p.published_at)
return render_template(
"patreon.html",
configuration=helper,
channels=channels,
roles=roles,
mention_everyone=mention_everyone,
mention_here=mention_here,
mention_role_ids=mention_role_ids,
posts=posts,
)
@webapp.route("/patreon/update", methods=["POST"])
@require_page("patreon")
def updatePatreon():
if not can_write_page("patreon"):
return render_template("403.html"), 403
helper = ConfigurationHelper()
enable = request.form.get("patreon_enable") in ("on", "1", "true", "yes")
creator = (request.form.get("patreon_creator") or "").strip()
channel_id = request.form.get("patreon_channel_id")
mention_parts = []
if request.form.get("patreon_mention_everyone"):
mention_parts.append("everyone")
if request.form.get("patreon_mention_here"):
mention_parts.append("here")
mention_parts.extend(request.form.getlist("patreon_mention_roles"))
helper.createOrUpdate("patreon_enable", "true" if enable else "false")
helper.createOrUpdate("patreon_creator", creator)
if channel_id:
try:
helper.createOrUpdate("patreon_channel_id", str(int(channel_id)))
except ValueError:
pass
helper.createOrUpdate("patreon_mention", ",".join(mention_parts))
db.session.commit()
return redirect(url_for("openPatreon") + "?msg=Configuration enregistrée.&type=success")
@webapp.route("/patreon/send", methods=["POST"])
@require_page("patreon")
def sendPatreonToDiscord():
if not can_write_page("patreon"):
return render_template("403.html"), 403
guid = (request.form.get("guid") or "").strip()
if not guid:
return redirect(url_for("openPatreon") + "?" + urlencode({"msg": "Post manquant.", "type": "error"}))
ok, message = send_post_to_discord_sync(bot, guid)
msg_type = "success" if ok else "error"
return redirect(url_for("openPatreon") + "?" + urlencode({"msg": message, "type": msg_type}))
+11 -3
View File
@@ -1,23 +1,31 @@
from flask import render_template, request, redirect, url_for from flask import render_template, request, redirect, url_for
from webapp import webapp from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db from database import db
from database.models import GameAlias from database.models import GameAlias
from database.helpers import ConfigurationHelper from database.helpers import ConfigurationHelper
@webapp.route("/protondb") @webapp.route("/protondb")
@require_page("protondb")
def openProtonDB(): def openProtonDB():
aliases = GameAlias.query.all() aliases = GameAlias.query.all()
return render_template("protondb.html", aliases = aliases, configuration = ConfigurationHelper()) return render_template("protondb.html", aliases=aliases, configuration=ConfigurationHelper())
@webapp.route("/protondb/gamealias/add", methods=['POST']) @webapp.route("/protondb/gamealias/add", methods=['POST'])
@require_page("protondb")
def addGameAlias(): def addGameAlias():
game_alias = GameAlias(alias = request.form.get('alias'), name = request.form.get('name')) if not can_write_page("protondb"):
return render_template("403.html"), 403
game_alias = GameAlias(alias=request.form.get('alias'), name=request.form.get('name'))
db.session.add(game_alias) db.session.add(game_alias)
db.session.commit() db.session.commit()
return redirect(url_for('openProtonDB')) return redirect(url_for('openProtonDB'))
@webapp.route('/protondb/gamealias/del/<int:id>') @webapp.route('/protondb/gamealias/del/<int:id>')
def delGameAlias(id : int): @require_page("protondb")
def delGameAlias(id: int):
if not can_write_page("protondb"):
return render_template("403.html"), 403
GameAlias.query.filter_by(id=id).delete() GameAlias.query.filter_by(id=id).delete()
db.session.commit() db.session.commit()
return redirect(url_for('openProtonDB')) return redirect(url_for('openProtonDB'))
+329
View File
@@ -0,0 +1,329 @@
# Paramètres webapp : rôles, permissions par page, inscriptions (super administrateur uniquement).
from flask import render_template, request, redirect, url_for, flash
from webapp import webapp
from webapp.auth import require_page
from database import db
from database.models import WebappRole, PagePermission, WebappUser
from database.helpers import ConfigurationHelper
# Métadonnées des pages : catégorie, label d'affichage, description
PAGE_METADATA = {
"index": {
"label": "Tableau de bord",
"category": "general",
"description": "Page d'accueil avec aperçu du système",
"icon": "home"
},
"commandes": {
"label": "Commandes",
"category": "content",
"description": "Gérer les commandes Discord et Twitch",
"icon": "terminal"
},
"configurations": {
"label": "Configurations",
"category": "config",
"description": "Paramètres généraux du bot",
"icon": "settings"
},
"humeurs": {
"label": "Humeurs",
"category": "content",
"description": "Gérer les statuts du bot Discord",
"icon": "smile"
},
"protondb": {
"label": "ProtonDB",
"category": "content",
"description": "Recherche de compatibilité des jeux Linux",
"icon": "gamepad"
},
"live_alert": {
"label": "Alertes Live",
"category": "content",
"description": "Notifications Discord pour les streams Twitch",
"icon": "bell"
},
"youtube": {
"label": "YouTube",
"category": "content",
"description": "Notifications Discord pour les vidéos YouTube",
"icon": "video"
},
"announcements": {
"label": "Annonces Twitch",
"category": "content",
"description": "Messages automatiques dans le chat Twitch",
"icon": "megaphone"
},
"moderation": {
"label": "Modération Discord",
"category": "moderation",
"description": "Historique de modération Discord",
"icon": "shield"
},
"twitch_moderation": {
"label": "Modération Twitch",
"category": "moderation",
"description": "Commandes et logs de modération Twitch",
"icon": "shield-check"
},
"link_filter": {
"label": "Filtre de liens",
"category": "moderation",
"description": "Filtrage automatique des liens Twitch",
"icon": "filter"
},
"twitch_events": {
"label": "Événements Twitch",
"category": "content",
"description": "Notifications subs, follows, raids, clips",
"icon": "star"
},
"freeloot": {
"label": "Free Loot",
"category": "content",
"description": "Flux RSS de jeux gratuits vers Discord",
"icon": "gift"
},
"users": {
"label": "Utilisateurs",
"category": "admin",
"description": "Gestion des comptes et rôles webapp",
"icon": "users"
},
"settings": {
"label": "Paramètres",
"category": "admin",
"description": "Rôles, permissions et inscriptions",
"icon": "cog"
},
}
# Labels des catégories
CATEGORY_LABELS = {
"general": {"label": "Général", "color": "#6B7280", "icon": "layout"},
"content": {"label": "Contenu", "color": "#3B82F6", "icon": "file-text"},
"moderation": {"label": "Modération", "color": "#EF4444", "icon": "shield"},
"config": {"label": "Configuration", "color": "#8B5CF6", "icon": "settings"},
"admin": {"label": "Administration", "color": "#F59E0B", "icon": "crown"},
}
# Rôles par défaut avec métadonnées
DEFAULT_ROLES = {
"viewer_twitch": {
"description": "Accès minimal, consultation uniquement",
"color": "#9146FF",
"icon": "eye"
},
"utilisateur_discord": {
"description": "Peut consulter et modifier du contenu basique",
"color": "#5865F2",
"icon": "user"
},
"moderateur_discord": {
"description": "Accès aux outils de modération Discord",
"color": "#57F287",
"icon": "shield"
},
"expert_discord": {
"description": "Gestion avancée du contenu et des configurations",
"color": "#FEE75C",
"icon": "star"
},
"moderateur_twitch": {
"description": "Accès aux outils de modération Twitch",
"color": "#9146FF",
"icon": "shield-check"
},
"super_administrateur": {
"description": "Accès complet à toutes les fonctionnalités",
"color": "#ED4245",
"icon": "crown"
},
}
PAGE_KEYS = [
("index", "Tableau de bord"),
("configurations", "Configurations"),
("commandes", "Commandes"),
("humeurs", "Humeurs"),
("live_alert", "Alerte live"),
("announcements", "Annonces Twitch"),
("twitch_moderation", "Modération Twitch"),
("link_filter", "Filtre de liens"),
("twitch_events", "Notifications événements Twitch"),
("youtube", "YouTube"),
("protondb", "ProtonDB"),
("freeloot", "FreeLoot"),
("moderation", "Modération Discord"),
("users", "Utilisateurs"),
("settings", "Paramètres"),
]
@webapp.route("/settings")
@require_page("settings")
def settings():
roles = WebappRole.query.order_by(WebappRole.level).all()
permissions = PagePermission.query.all()
perm_by_key = {p.page_key: p for p in permissions}
reg_enabled = ConfigurationHelper().getValue("registration_enabled") not in (None, "", "false", "0", "no", "off")
# Organiser les pages par catégorie
pages_by_category = {}
for page_key, meta in PAGE_METADATA.items():
category = meta.get("category", "general")
if category not in pages_by_category:
pages_by_category[category] = []
pages_by_category[category].append({
"key": page_key,
"meta": meta,
"permission": perm_by_key.get(page_key)
})
# Trier les pages dans chaque catégorie par label
for category in pages_by_category:
pages_by_category[category].sort(key=lambda x: x["meta"]["label"])
return render_template(
"settings.html",
roles=roles,
pages_by_category=pages_by_category,
category_labels=CATEGORY_LABELS,
perm_by_key=perm_by_key,
registration_enabled=reg_enabled,
page_metadata=PAGE_METADATA,
default_roles_meta=DEFAULT_ROLES,
)
@webapp.route("/settings/registration", methods=["POST"])
@require_page("settings")
def settings_toggle_registration():
enabled = request.form.get("enabled") in ("1", "true", "on", "yes")
ConfigurationHelper().createOrUpdate("registration_enabled", "true" if enabled else "false")
db.session.commit()
flash("Inscriptions " + ("activées" if enabled else "désactivées") + ".", "success")
return redirect(url_for("settings"))
@webapp.route("/settings/roles/add", methods=["POST"])
@require_page("settings")
def settings_role_add():
name = (request.form.get("name") or "").strip()
level_str = request.form.get("level", "0")
description = (request.form.get("description") or "").strip()
color = (request.form.get("color") or "#6B7280").strip()
icon = (request.form.get("icon") or "").strip()
if not name:
flash("Nom du rôle requis.", "error")
return redirect(url_for("settings"))
try:
level = int(level_str)
except ValueError:
level = 0
if WebappRole.query.filter_by(name=name).first():
flash(f"Le rôle « {name} » existe déjà.", "error")
return redirect(url_for("settings"))
role = WebappRole(
name=name,
level=level,
description=description if description else None,
color=color,
icon=icon if icon else None
)
db.session.add(role)
db.session.commit()
flash(f"Rôle « {name} » créé.", "success")
return redirect(url_for("settings"))
@webapp.route("/settings/roles/<int:role_id>/edit", methods=["POST"])
@require_page("settings")
def settings_role_edit(role_id):
role = WebappRole.query.get_or_404(role_id)
level_str = request.form.get("level")
description = request.form.get("description")
color = request.form.get("color")
icon = request.form.get("icon")
if level_str is not None:
try:
role.level = int(level_str)
except ValueError:
flash("Niveau invalide.", "error")
return redirect(url_for("settings"))
if description is not None:
role.description = description.strip() if description.strip() else None
if color is not None:
role.color = color.strip() if color.strip() else "#6B7280"
if icon is not None:
role.icon = icon.strip() if icon.strip() else None
db.session.commit()
flash(f"Rôle « {role.name} » mis à jour.", "success")
return redirect(url_for("settings"))
@webapp.route("/settings/roles/<int:role_id>/delete", methods=["POST"])
@require_page("settings")
def settings_role_delete(role_id):
role = WebappRole.query.get_or_404(role_id)
if WebappUser.query.filter_by(role=role.name).count() > 0:
flash(f"Impossible de supprimer le rôle « {role.name} » : des utilisateurs l'utilisent.", "error")
return redirect(url_for("settings"))
db.session.delete(role)
db.session.commit()
flash(f"Rôle « {role.name} » supprimé.", "success")
return redirect(url_for("settings"))
@webapp.route("/settings/permissions/update", methods=["POST"])
@require_page("settings")
def settings_permissions_update():
page_key = request.form.get("page_key")
role_name = request.form.get("role")
if not page_key:
return redirect(url_for("settings"))
role = WebappRole.query.filter_by(name=role_name).first()
level = role.level if role else 0
perm = PagePermission.query.filter_by(page_key=page_key).first()
if perm:
perm.min_level = level
perm.write_level = level
else:
perm = PagePermission(page_key=page_key, min_level=level, write_level=level)
db.session.add(perm)
db.session.commit()
flash(f"Accès à « {page_key} » mis à jour.", "success")
return redirect(url_for("settings"))
@webapp.route("/settings/permissions/bulk", methods=["POST"])
@require_page("settings")
def settings_permissions_bulk():
page_keys = request.form.getlist("page_keys")
role_name = request.form.get("role")
if not page_keys or not role_name:
flash("Sélectionnez au moins une page et un rôle.", "error")
return redirect(url_for("settings"))
role = WebappRole.query.filter_by(name=role_name).first()
level = role.level if role else 0
updated = 0
for page_key in page_keys:
perm = PagePermission.query.filter_by(page_key=page_key).first()
if perm:
perm.min_level = level
perm.write_level = level
else:
perm = PagePermission(page_key=page_key, min_level=level, write_level=level)
db.session.add(perm)
updated += 1
db.session.commit()
flash(f"Accès mis à jour pour {updated} page(s) avec le rôle « {role_name} ».", "success")
return redirect(url_for("settings"))
-603
View File
@@ -1,603 +0,0 @@
/* MVP.css v1.17.2 - https://github.com/andybrewer/mvp */
:root {
--active-brightness: 0.85;
--border-radius: 5px;
--box-shadow: 2px 2px 10px;
--color-accent: #118bee15;
--color-bg: #fff;
--color-bg-secondary: #e9e9e9;
--color-link: #118bee;
--color-secondary: #920de9;
--color-secondary-accent: #920de90b;
--color-shadow: #f4f4f4;
--color-table: #118bee;
--color-text: #000;
--color-text-secondary: #999;
--color-scrollbar: #cacae8;
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
--hover-brightness: 1.2;
--justify-important: center;
--justify-normal: left;
--line-height: 1.5;
--width-card: 285px;
--width-card-medium: 460px;
--width-card-wide: 800px;
--width-content: 1080px;
}
@media (prefers-color-scheme: dark) {
:root[color-mode="user"] {
--color-accent: #0097fc4f;
--color-bg: #333;
--color-bg-secondary: #555;
--color-link: #0097fc;
--color-secondary: #e20de9;
--color-secondary-accent: #e20de94f;
--color-shadow: #bbbbbb20;
--color-table: #0097fc;
--color-text: #f7f7f7;
--color-text-secondary: #aaa;
}
}
html {
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
/* Layout */
article aside {
background: var(--color-secondary-accent);
border-left: 4px solid var(--color-secondary);
padding: 0.01rem 0.8rem;
}
body {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-family);
line-height: var(--line-height);
margin: 0;
overflow-x: hidden;
padding: 0;
}
footer,
header,
main {
margin: 0 auto;
max-width: var(--width-content);
/* padding: 3rem 1rem; */
padding: 1rem 1rem;
}
hr {
background-color: var(--color-bg-secondary);
border: none;
height: 1px;
margin: 4rem 0;
width: 100%;
}
section {
display: flex;
flex-wrap: wrap;
justify-content: var(--justify-important);
}
section img,
article img {
max-width: 100%;
}
section pre {
overflow: auto;
}
section aside {
border: 1px solid var(--color-bg-secondary);
border-radius: var(--border-radius);
box-shadow: var(--box-shadow) var(--color-shadow);
margin: 1rem;
padding: 1.25rem;
width: var(--width-card);
}
section aside:hover {
box-shadow: var(--box-shadow) var(--color-bg-secondary);
}
[hidden] {
display: none;
}
/* Headers */
article header,
div header,
main header {
padding-top: 0;
}
header {
text-align: var(--justify-important);
}
header a b,
header a em,
header a i,
header a strong {
margin-left: 0.5rem;
margin-right: 0.5rem;
}
/* header nav img {
margin: 1rem 0;
} */
section header {
padding-top: 0;
width: 100%;
}
/* Nav */
nav {
align-items: center;
display: flex;
font-weight: bold;
justify-content: space-between;
/* margin-bottom: 7rem; */
}
nav ul {
list-style: none;
padding: 0;
}
nav ul li {
display: inline-block;
margin: 0 0.5rem;
position: relative;
text-align: left;
}
/* Nav Dropdown */
nav ul li:hover ul {
display: block;
}
nav ul li ul {
background: var(--color-bg);
border: 1px solid var(--color-bg-secondary);
border-radius: var(--border-radius);
box-shadow: var(--box-shadow) var(--color-shadow);
display: none;
height: auto;
left: -2px;
padding: 0.5rem 1rem;
position: absolute;
top: 1.7rem;
white-space: nowrap;
width: auto;
z-index: 1;
}
nav ul li ul::before {
/* fill gap above to make mousing over them easier */
content: "";
position: absolute;
left: 0;
right: 0;
top: -0.5rem;
height: 0.5rem;
}
nav ul li ul li,
nav ul li ul li a {
display: block;
}
/* Nav for Mobile */
@media (max-width: 768px) {
nav {
flex-wrap: wrap;
}
nav ul li {
width: calc(100% - 1em);
}
nav ul li ul {
border: none;
box-shadow: none;
display: block;
position: static;
}
}
/* Typography */
code,
samp {
background-color: var(--color-accent);
border-radius: var(--border-radius);
color: var(--color-text);
display: inline-block;
margin: 0 0.1rem;
padding: 0 0.5rem;
}
details {
margin: 1.3rem 0;
}
details summary {
font-weight: bold;
cursor: pointer;
}
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: var(--line-height);
text-wrap: balance;
}
mark {
padding: 0.1rem;
}
ol li,
ul li {
padding: 0.2rem 0;
}
p {
margin: 0.75rem 0;
padding: 0;
width: 100%;
}
pre {
margin: 1rem 0;
max-width: var(--width-card-wide);
padding: 1rem 0;
}
pre code,
pre samp {
display: block;
max-width: var(--width-card-wide);
padding: 0.5rem 2rem;
white-space: pre-wrap;
}
small {
color: var(--color-text-secondary);
}
sup {
background-color: var(--color-secondary);
border-radius: var(--border-radius);
color: var(--color-bg);
font-size: xx-small;
font-weight: bold;
margin: 0.2rem;
padding: 0.2rem 0.3rem;
position: relative;
top: -2px;
}
/* Links */
a {
color: var(--color-link);
display: inline-block;
font-weight: bold;
text-decoration: underline;
}
a:hover {
filter: brightness(var(--hover-brightness));
}
a:active {
filter: brightness(var(--active-brightness));
}
a b,
a em,
a i,
a strong,
button,
input[type="submit"] {
border-radius: var(--border-radius);
display: inline-block;
font-size: medium;
font-weight: bold;
line-height: var(--line-height);
margin: 0.5rem 0;
padding: 1rem 2rem;
}
button,
input[type="submit"] {
font-family: var(--font-family);
}
button:hover,
input[type="submit"]:hover {
cursor: pointer;
filter: brightness(var(--hover-brightness));
}
button:active,
input[type="submit"]:active {
filter: brightness(var(--active-brightness));
}
a b,
a strong,
button,
input[type="submit"] {
background-color: var(--color-link);
border: 2px solid var(--color-link);
color: var(--color-bg);
}
a em,
a i {
border: 2px solid var(--color-link);
border-radius: var(--border-radius);
color: var(--color-link);
display: inline-block;
padding: 1rem 2rem;
}
article aside a {
color: var(--color-secondary);
}
/* Images */
figure {
margin: 0;
padding: 0;
}
figure img {
max-width: 100%;
}
figure figcaption {
color: var(--color-text-secondary);
}
/* Forms */
button:disabled,
input:disabled {
background: var(--color-bg-secondary);
border-color: var(--color-bg-secondary);
color: var(--color-text-secondary);
cursor: not-allowed;
}
button[disabled]:hover,
input[type="submit"][disabled]:hover {
filter: none;
}
form {
border: 1px solid var(--color-bg-secondary);
border-radius: var(--border-radius);
box-shadow: var(--box-shadow) var(--color-shadow);
display: block;
max-width: var(--width-card-wide);
min-width: var(--width-card);
padding: 1.5rem;
text-align: var(--justify-normal);
}
form header {
margin: 1.5rem 0;
padding: 1.5rem 0;
}
input,
label,
select,
textarea {
display: block;
font-size: inherit;
max-width: var(--width-card-wide);
}
input[type="checkbox"],
input[type="radio"] {
display: inline-block;
}
input[type="checkbox"]+label,
input[type="radio"]+label {
display: inline-block;
font-weight: normal;
position: relative;
top: 1px;
}
input[type="range"] {
padding: 0.4rem 0;
}
input,
select,
textarea {
border: 1px solid var(--color-bg-secondary);
border-radius: var(--border-radius);
margin-bottom: 1rem;
padding: 0.4rem 0.8rem;
}
input[type="text"],
input[type="password"],
input[type="email"],
textarea {
width: calc(100% - 1.6rem);
}
input[readonly],
textarea[readonly] {
background-color: var(--color-bg-secondary);
}
label {
font-weight: bold;
margin-bottom: 0.2rem;
}
/* Popups */
dialog {
max-width: 90%;
max-height: 85dvh;
margin: auto;
padding-block: 0;
padding-inline: 20px;
border: 1px solid var(--color-bg-secondary);
border-radius: 0.5rem;
overscroll-behavior: contain;
scroll-behavior: smooth;
scrollbar-width: none;
/* Hide scrollbar for Firefox */
-ms-overflow-style: none;
/* Hide scrollbar for IE and Edge */
scrollbar-color: transparent transparent;
animation: bottom-to-top 0.25s ease-in-out forwards;
}
dialog::-webkit-scrollbar {
width: 0;
display: none;
}
dialog::-webkit-scrollbar-track {
background: transparent;
}
dialog::-webkit-scrollbar-thumb {
background-color: transparent;
}
@media (min-width: 650px) {
dialog {
max-width: 39rem;
}
}
dialog::backdrop {
background-color: rgba(0, 0, 0, 0.5);
}
@keyframes bottom-to-top {
0% {
opacity: 0;
transform: translateY(10%);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
dialog hr {
margin-block: 1rem;
}
/* Tables */
table {
border: 1px solid var(--color-bg-secondary);
border-radius: var(--border-radius);
border-spacing: 0;
display: inline-block;
max-width: 100%;
overflow-x: auto;
padding: 0;
white-space: nowrap;
}
table td,
table th,
table tr {
padding: 0.4rem 0.8rem;
text-align: var(--justify-important);
}
table thead {
background-color: var(--color-table);
border-collapse: collapse;
border-radius: var(--border-radius);
color: var(--color-bg);
margin: 0;
padding: 0;
}
table thead tr:first-child th:first-child {
border-top-left-radius: var(--border-radius);
}
table thead tr:first-child th:last-child {
border-top-right-radius: var(--border-radius);
}
table thead th:first-child,
table tr td:first-child {
text-align: var(--justify-normal);
}
table tr:nth-child(even) {
background-color: var(--color-accent);
}
/* Quotes */
blockquote {
display: block;
font-size: x-large;
line-height: var(--line-height);
margin: 1rem auto;
max-width: var(--width-card-medium);
padding: 1.5rem 1rem;
text-align: var(--justify-important);
}
blockquote footer {
color: var(--color-text-secondary);
display: block;
font-size: small;
line-height: var(--line-height);
padding: 1.5rem 0;
}
/* Scrollbars */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-scrollbar) transparent;
}
*::-webkit-scrollbar {
width: 5px;
height: 5px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: var(--color-scrollbar);
border-radius: 10px;
}
-19
View File
@@ -1,19 +0,0 @@
header nav img {
border-radius: 50%;
}
table th,
table td {
text-align: left;
vertical-align: top;
overflow: hidden;
white-space: normal;
}
table.live-alert tr td:last-child {
white-space: nowrap;
}
a.icon {
text-decoration: none;
}
+9
View File
@@ -0,0 +1,9 @@
{% extends "template.html" %}
{% block content %}
<div class="max-w-md mx-auto py-12 text-center">
<h1 class="text-2xl font-bold text-slate-800 dark:text-white mb-2">Accès refusé</h1>
<p class="text-slate-600 dark:text-slate-400 mb-6">Vous n'avez pas les droits nécessaires pour accéder à cette page.</p>
<a href="{{ url_for('index') }}" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">Retour à l'accueil</a>
</div>
{% endblock %}
+188
View File
@@ -0,0 +1,188 @@
{% extends "template.html" %}
{% block content %}
<div class="space-y-8">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"></path>
</svg>
</div>
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Annonces Twitch</h1>
<p class="text-gray-600 dark:text-gray-400">Messages automatiques périodiques dans le chat</p>
</div>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Configurez des messages envoyés automatiquement dans le chat Twitch.
L'annonce est envoyée uniquement si le temps est écoulé ET si le nombre minimum de messages a été atteint.
</p>
</div>
{% if not announcement %}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Annonces configurées</h2>
</div>
{% if announcements %}
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Nom</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Message</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Temps</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Min. messages</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Dernier envoi</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for ann in announcements %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
<td class="px-6 py-4">
<span class="font-medium text-gray-900 dark:text-white">{{ ann.name }}</span>
</td>
<td class="px-6 py-4">
<span class="text-gray-600 dark:text-gray-300 text-sm max-w-xs truncate block">{{ ann.text[:60] }}{% if ann.text|length > 60 %}...{% endif %}</span>
</td>
<td class="px-6 py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
{{ ann.periodicity }} min
</span>
</td>
<td class="px-6 py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
{{ ann.min_chat_messages }}
</span>
</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{% if ann.last_sent %}
{{ ann.last_sent.strftime('%d/%m %H:%M') }}
{% else %}
<span class="italic">Jamais</span>
{% endif %}
</td>
<td class="px-6 py-4 text-right">
<div class="flex items-center justify-end gap-2">
<a href="{{ url_for('toggleAnnouncement', id=ann.id) }}"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="{{ 'Désactiver' if ann.enable else 'Activer' }}">
{% if ann.enable %}
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
{% else %}
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
{% endif %}
</a>
<a href="{{ url_for('resetAnnouncement', id=ann.id) }}"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Remettre le compteur à zéro">
<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
</a>
<a href="{{ url_for('openEditAnnouncement', id=ann.id) }}"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Modifier">
<svg class="w-5 h-5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
</a>
<a href="{{ url_for('delAnnouncement', id=ann.id) }}"
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette annonce ?')"
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
title="Supprimer">
<svg class="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="px-6 py-12 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"></path>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">Aucune annonce</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Commencez par créer votre première annonce automatique.</p>
</div>
{% endif %}
</div>
{% endif %}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-6">
{{ 'Modifier l\'annonce' if announcement else 'Ajouter une annonce' }}
</h2>
<form action="{{ url_for('submitEditAnnouncement', id=announcement.id) if announcement else url_for('addAnnouncement') }}" method="POST" class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Nom de l'annonce
</label>
<input type="text" name="name" id="name" required maxlength="64"
value="{{ announcement.name if announcement else '' }}"
placeholder="Ex: Règles du chat"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors">
</div>
<div>
<label for="periodicity" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Temps entre les annonces (minutes)
</label>
<input type="number" name="periodicity" id="periodicity" required min="1" max="1440"
value="{{ announcement.periodicity if announcement else 10 }}"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">1 min à 1440 min (24h)</p>
</div>
<div>
<label for="min_chat_messages" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Messages minimum entre annonces
</label>
<input type="number" name="min_chat_messages" id="min_chat_messages" required min="0" max="1000"
value="{{ announcement.min_chat_messages if announcement else 0 }}"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">0 = pas de minimum</p>
</div>
</div>
<div>
<label for="text" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Message
</label>
<textarea name="text" id="text" required maxlength="500" rows="4"
placeholder="Le message qui sera envoyé dans le chat..."
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors resize-none">{{ announcement.text if announcement else '' }}</textarea>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Maximum 500 caractères</p>
</div>
<div class="flex items-center gap-4">
<button type="submit"
class="px-6 py-2 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2">
{{ 'Enregistrer' if announcement else 'Ajouter' }}
</button>
{% if announcement %}
<a href="{{ url_for('openAnnouncements') }}"
class="px-6 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 font-medium rounded-lg transition-colors">
Annuler
</a>
{% endif %}
</div>
</form>
</div>
</div>
{% endblock %}
+107 -32
View File
@@ -1,55 +1,130 @@
{% extends "template.html" %} {% extends "template.html" %}
{% block content %} {% block content %}
<h1>Commandes de Mamie</h1> <div class="mb-8">
<p>Gérez les commandes personnalisées du bot. Ces commandes peuvent être activées sur Discord et/ou Twitch selon vos besoins.</p> <h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Commandes de Mamie</h1>
<table> <div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<thead> <p class="text-gray-700 dark:text-gray-300">
Gérez les commandes personnalisées du bot. Ces commandes peuvent être activées sur Discord et/ou Twitch selon vos besoins.
</p>
</div>
</div>
<div class="mb-8">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Liste des commandes</h2>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr> <tr>
<th>Commande</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Commande</th>
<th>Réponse</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Réponse</th>
<th>Discord</th> <th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Discord</th>
<th>Twitch</th> <th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Twitch</th>
<th>Actions</th> <th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Permission Twitch</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for commande in commandes %} {% for commande in commandes %}
<tr> <tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td>{{ commande.trigger }}</td> <td class="px-6 py-4 whitespace-nowrap">
<td>{{ commande.response }}</td> <code class="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-purple-600 dark:text-purple-400 font-mono">{{ commande.trigger }}</code>
<td> </td>
<a href="{{ url_for('toggle_discord_commande', commande_id = commande.id) }}" class="icon"> <td class="px-6 py-4 text-gray-600 dark:text-gray-400 max-w-md truncate">{{ commande.response }}</td>
<td class="px-6 py-4 text-center">
<a href="{{ url_for('toggle_discord_commande', commande_id = commande.id) }}"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors inline-block"
title="{{ 'Désactiver sur Discord' if commande.discord_enable else 'Activer sur Discord' }}">
{{ '✅' if commande.discord_enable else '❌' }} {{ '✅' if commande.discord_enable else '❌' }}
</a> </a>
</td> </td>
<td> <td class="px-6 py-4 text-center">
<a href="{{ url_for('toggle_twitch_commande', commande_id = commande.id) }}" class="icon"> <a href="{{ url_for('toggle_twitch_commande', commande_id = commande.id) }}"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors inline-block"
title="{{ 'Désactiver sur Twitch' if commande.twitch_enable else 'Activer sur Twitch' }}">
{{ '✅' if commande.twitch_enable else '❌' }} {{ '✅' if commande.twitch_enable else '❌' }}
</a> </a>
</td> </td>
<td> <td class="px-6 py-4 text-center">
<a href="{{ url_for('delete_commande', commande_id = commande.id) }}" onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette commande ?')">Supprimer</a> {% if commande.twitch_enable %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300">
{{ twitch_permissions.get(commande.twitch_permission or 'viewer', 'Tous') }}
</span>
{% else %}
<span class="text-gray-400 dark:text-gray-500"></span>
{% endif %}
</td>
<td class="px-6 py-4 text-center">
<a href="{{ url_for('delete_commande', commande_id = commande.id) }}"
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette commande ?')"
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors text-red-600 dark:text-red-400 inline-block"
title="Supprimer">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
Aucune commande configurée. Ajoutez-en une ci-dessous.
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
</div>
</div>
<h2>Ajouter une commande</h2> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<form action="{{ url_for('add_commande') }}" method="POST"> <h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Ajouter une commande</h2>
<label for="trigger">Commande</label>
<input name="trigger" type="text" /> <form action="{{ url_for('add_commande') }}" method="POST" class="space-y-6">
<label for="response">Réponse</label> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<textarea name="response" rows="5" cols="50"></textarea>
<div> <div>
<label for="discord_enable">Discord</label> <label for="trigger" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Commande</label>
<input name="discord_enable" type="checkbox" checked /> <input name="trigger" id="trigger" type="text" required
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
placeholder="!macommande"/>
</div> </div>
<div class="flex flex-wrap items-end gap-6">
<label class="flex items-center gap-2 cursor-pointer">
<input name="discord_enable" type="checkbox" checked
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Discord</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input name="twitch_enable" type="checkbox" id="twitch_enable_checkbox"
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Twitch</span>
</label>
<div class="w-full sm:w-auto">
<label for="twitch_permission" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Permission Twitch (qui peut utiliser la commande)</label>
<select name="twitch_permission" id="twitch_permission"
class="w-full sm:w-48 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent">
{% for value, label in twitch_permissions.items() %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div> <div>
<label for="twitch_enable">Twitch</label> <label for="response" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Réponse</label>
<input name="twitch_enable" type="checkbox" unchecked /> <textarea name="response" id="response" rows="4" required
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all resize-y"
placeholder="La réponse que le bot enverra..."></textarea>
</div> </div>
<input type="Submit" value="Ajouter">
</form> <div>
<button type="submit"
class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Ajouter la commande
</button>
</div>
</form>
</div>
{% endblock %} {% endblock %}
+379 -183
View File
@@ -1,112 +1,259 @@
{% extends "template.html" %} {% extends "template.html" %}
{% block content %} {% block content %}
<h1>Configuration de Mamie</h1> {% with messages = get_flashed_messages(with_categories=true) %}
<p>Configurez les tokens Discord, les notifications Humble Bundle et l'API Twitch.</p> {% if messages %}
<div class="mb-4 space-y-2">
{% for category, msg in messages %}
<div class="p-4 rounded-lg {{ 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-200' if category == 'success' else 'bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-200' }}">
{{ msg }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Configuration de Mamie</h1>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p class="text-gray-700 dark:text-gray-300">
Configurez les tokens Discord, les notifications Humble Bundle et l'API Twitch.
</p>
</div>
</div>
<h2>Discord</h2> <div class="space-y-8">
<form action="{{ url_for('updateConfiguration') }}" method="POST"> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<fieldset> <div class="flex items-center gap-3 mb-6">
<legend>API Discord</legend> <svg class="w-6 h-6 text-indigo-500" fill="currentColor" viewBox="0 0 24 24"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
<label for="discord_token">Token Discord (caché)</label> <h2 class="text-xl font-semibold text-gray-900 dark:text-white">Discord</h2>
<input name="discord_token" type="password" placeholder="Votre token Discord" /> </div>
<small>Nécessite un redémarrage après modification</small>
</fieldset>
<fieldset> <form action="{{ url_for('updateConfiguration') }}" method="POST" class="space-y-6">
<legend>Messages de bienvenue</legend> <div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<label for="welcome_enable"> <h3 class="font-medium text-gray-800 dark:text-gray-200">API Discord</h3>
<input type="checkbox" name="welcome_enable" {% if configuration.getValue('welcome_enable') %}checked="checked"{% endif %}> <div>
Activer le message de bienvenue pour les nouveaux membres <label for="discord_token" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Token Discord (caché)</label>
<input name="discord_token" id="discord_token" type="password"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
placeholder="Votre token Discord"/>
<p class="mt-1 text-xs text-amber-600 dark:text-amber-400">Nécessite un redémarrage après modification</p>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Messages de bienvenue</h3>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="welcome_enable" {% if configuration.getValue('welcome_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">Activer le message de bienvenue pour les nouveaux membres</span>
</label> </label>
<label for="welcome_channel_id">Canal de bienvenue</label> <div>
<select name="welcome_channel_id"> <label for="welcome_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal de bienvenue</label>
<select name="welcome_channel_id" id="welcome_channel_id"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all">
{% for channel in channels %} {% for channel in channels %}
<option value="{{channel.id}}" {% if configuration.getIntValue('welcome_channel_id')==channel.id %}selected="selected"{% endif %}> <option value="{{channel.id}}" {% if configuration.getIntValue('welcome_channel_id')==channel.id %}selected{% endif %}>{{channel.name}}</option>
{{channel.name}}
</option>
{% endfor %} {% endfor %}
</select> </select>
</div>
<label for="welcome_message">Message personnalisé de bienvenue</label> <div>
<textarea name="welcome_message" rows="3" placeholder="Bienvenue {member.mention} sur le serveur !">{{ configuration.getValue('welcome_message') }}</textarea> <label for="welcome_message" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Message personnalisé</label>
<small> <textarea name="welcome_message" id="welcome_message" rows="3"
<strong>Syntaxes disponibles :</strong><br> class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all resize-y"
<code>{member.mention}</code> - Mentionne l'utilisateur (@NomUtilisateur)<br> placeholder="Bienvenue {member.mention} sur le serveur !">{{ configuration.getValue('welcome_message') }}</textarea>
<code>{member.name}</code> - Nom d'utilisateur (sans mention)<br> <div class="mt-2 text-xs text-gray-500 dark:text-gray-400 space-y-1">
<code>{member.display_name}</code> - Surnom sur le serveur<br> <p><code class="px-1 bg-gray-200 dark:bg-gray-600 rounded">{member.mention}</code> Mentionne l'utilisateur</p>
<code>{member.id}</code> - ID de l'utilisateur<br> <p><code class="px-1 bg-gray-200 dark:bg-gray-600 rounded">{member.name}</code> Nom d'utilisateur</p>
<code>{server.name}</code> - Nom du serveur<br> <p><code class="px-1 bg-gray-200 dark:bg-gray-600 rounded">{server.name}</code> Nom du serveur</p>
<code>{server.member_count}</code> - Nombre total de membres<br> <p><code class="px-1 bg-gray-200 dark:bg-gray-600 rounded">{server.member_count}</code> Nombre de membres</p>
<code>&lt;#ID_DU_CHANNEL&gt;</code> - Mentionne un salon (ex: &lt;#123456789012345678&gt;) </div>
</small> </div>
</fieldset> </div>
<fieldset> <div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<legend>Messages de départ</legend> <h3 class="font-medium text-gray-800 dark:text-gray-200">Règlement (embed + bouton)</h3>
<label for="leave_enable"> <p class="text-sm text-gray-600 dark:text-gray-400">
<input type="checkbox" name="leave_enable" {% if configuration.getValue('leave_enable') %}checked="checked"{% endif %}> Le <strong>rôle d'arrivée</strong> est attribué <strong>dès qu'un membre rejoint le serveur</strong>. Le <strong>rôle membre validé</strong> est attribué <strong>uniquement</strong> quand il clique sur le bouton « J'ai lu le règlement » (le rôle d'arrivée est alors retiré s'il est encore présent). Le canal présentation sert uniquement d'indication dans le message de confirmation après le clic.
Activer le message de départ quand un membre quitte le serveur </p>
<input type="hidden" name="rules_ack_section_in_form" value="1">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="rules_ack_enable" {% if configuration.getValue('rules_ack_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">Activer le règlement avec bouton</span>
</label> </label>
<div>
<label for="leave_channel_id">Canal de départ</label> <label for="rules_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal du règlement (message + bouton)</label>
<select name="leave_channel_id"> <select name="rules_channel_id" id="rules_channel_id"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all">
<option value="">— Choisir un canal —</option>
{% for channel in channels %} {% for channel in channels %}
<option value="{{channel.id}}" {% if configuration.getIntValue('leave_channel_id')==channel.id %}selected="selected"{% endif %}> <option value="{{ channel.id }}" {% if configuration.getIntValue('rules_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }}</option>
{{channel.name}}
</option>
{% endfor %} {% endfor %}
</select> </select>
</div>
<label for="leave_message">Message personnalisé de départ</label> <div>
<textarea name="leave_message" rows="3" placeholder="{member.mention} a quitté le serveur.">{{ configuration.getValue('leave_message') }}</textarea> <label for="rules_embed_title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Titre de l'embed</label>
<small> <input name="rules_embed_title" id="rules_embed_title" type="text"
<strong>Syntaxes disponibles :</strong><br> class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
<code>{member.mention}</code> - Mentionne l'utilisateur (@NomUtilisateur)<br> value="{{ configuration.getValue('rules_embed_title') or '' }}"
<code>{member.name}</code> - Nom d'utilisateur (sans mention)<br> placeholder="Bienvenue"/>
<code>{member.display_name}</code> - Surnom sur le serveur<br> </div>
<code>{member.id}</code> - ID de l'utilisateur<br> <div>
<code>{server.name}</code> - Nom du serveur<br> <label for="rules_embed_body" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Texte du règlement (description de l'embed, markdown Discord)</label>
<code>{server.member_count}</code> - Nombre total de membres<br> <textarea name="rules_embed_body" id="rules_embed_body" rows="8"
<code>&lt;#ID_DU_CHANNEL&gt;</code> - Mentionne un salon (ex: &lt;#123456789012345678&gt;) class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all resize-y"
</small> placeholder="Lis le règlement puis clique sur le bouton ci-dessous…">{{ configuration.getValue('rules_embed_body') or '' }}</textarea>
</fieldset> </div>
<div>
<fieldset> <label for="rules_button_label" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Libellé du bouton</label>
<legend>Modération</legend> <input name="rules_button_label" id="rules_button_label" type="text"
<label for="moderation_enable"> class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
<input type="checkbox" name="moderation_enable" {% if configuration.getValue('moderation_enable') %}checked="checked"{% endif %}> value="{{ configuration.getValue('rules_button_label') or '' }}"
Activer les commandes d'avertissement (!warn, !unwarn, !inspect) placeholder="J'ai lu le règlement"/>
</label> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<label for="moderation_ban_enable"> <div>
<input type="checkbox" name="moderation_ban_enable" {% if configuration.getValue('moderation_ban_enable') %}checked="checked"{% endif %}> <label for="rules_arrival_role_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rôle d'arrivée (à la connexion uniquement)</label>
Activer les commandes de bannissement (!ban, !unban) <select name="rules_arrival_role_id" id="rules_arrival_role_id"
</label> class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all">
<option value="">— Aucun —</option>
<label for="moderation_kick_enable"> {% for guild_data in roles %}
<input type="checkbox" name="moderation_kick_enable" {% if configuration.getValue('moderation_kick_enable') %}checked="checked"{% endif %}> <optgroup label="{{ guild_data.guild_name }}">
Activer la commande d'expulsion (!kick) {% for role in guild_data.roles %}
</label> <option value="{{ role.id }}" {% if configuration.getIntValue('rules_arrival_role_id') == role.id %}selected{% endif %}>{{ role.name }}</option>
{% endfor %}
<label for="moderation_log_channel_id">Canal de logs de modération</label> </optgroup>
<select name="moderation_log_channel_id">
{% for channel in channels %}
<option value="{{channel.id}}" {% if configuration.getIntValue('moderation_log_channel_id')==channel.id %}selected="selected"{% endif %}>
{{channel.name}}
</option>
{% endfor %} {% endfor %}
</select> </select>
<small>Toutes les actions de modération seront notifiées dans ce canal</small> </div>
<div>
<label for="rules_validated_role_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rôle membre validé (au clic sur le bouton uniquement)</label>
<select name="rules_validated_role_id" id="rules_validated_role_id"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all">
<option value="">— Aucun —</option>
{% for guild_data in roles %}
<optgroup label="{{ guild_data.guild_name }}">
{% for role in guild_data.roles %}
<option value="{{ role.id }}" {% if configuration.getIntValue('rules_validated_role_id') == role.id %}selected{% endif %}>{{ role.name }}</option>
{% endfor %}
</optgroup>
{% endfor %}
</select>
</div>
</div>
<div>
<label for="rules_presentation_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal présentation (optionnel, texte d'aide après le bouton)</label>
<select name="rules_presentation_channel_id" id="rules_presentation_channel_id"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all">
<option value="">— Désactivé —</option>
{% for channel in channels %}
<option value="{{ channel.id }}" {% if configuration.getIntValue('rules_presentation_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }}</option>
{% endfor %}
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Mentionné dans le message éphémère après le clic (« Tu peux aller te présenter dans … »). Aucun rôle n'est attribué automatiquement sur ce canal.</p>
</div>
</div>
<label>Rôles Staff autorisés</label> <div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Messages de départ</h3>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="leave_enable" {% if configuration.getValue('leave_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">Activer le message quand un membre quitte le serveur</span>
</label>
<div>
<label for="leave_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal de départ</label>
<select name="leave_channel_id" id="leave_channel_id"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all">
{% for channel in channels %}
<option value="{{channel.id}}" {% if configuration.getIntValue('leave_channel_id')==channel.id %}selected{% endif %}>{{channel.name}}</option>
{% endfor %}
</select>
</div>
<div>
<label for="leave_message" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Message personnalisé</label>
<textarea name="leave_message" id="leave_message" rows="3"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all resize-y"
placeholder="{member.name} a quitté le serveur.">{{ configuration.getValue('leave_message') }}</textarea>
</div>
</div>
<div id="auto-rooms" class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Auto Rooms (salons vocaux temporaires)</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Quand un membre rejoint le canal vocal configuré ci-dessous, un salon vocal temporaire est créé. Le message de configuration avec les réactions apparaît dans la <strong>partie texte du vocal</strong> (onglet Discussion à droite quand on ouvre le salon). Seul le propriétaire peut réagir.
</p>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="auto_rooms_enable" {% if configuration.getValue('auto_rooms_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">Activer les Auto Rooms</span>
</label>
<div>
<label for="auto_rooms_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal vocal à rejoindre pour créer un salon</label>
<select name="auto_rooms_channel_id" id="auto_rooms_channel_id"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all">
<option value="">— Choisir un canal vocal —</option>
{% for channel in voice_channels %}
<option value="{{ channel.id }}" {% if configuration.getIntValue('auto_rooms_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }} (vocal)</option>
{% endfor %}
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Ex. « + Créer votre salon » — les membres qui rejoignent ce canal obtiennent un salon vocal dédié.</p>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Modération</h3>
<div class="space-y-2">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="moderation_enable" {% if configuration.getValue('moderation_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">Activer les commandes d'avertissement (!warn, !unwarn, !inspect)</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="moderation_ban_enable" {% if configuration.getValue('moderation_ban_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">Activer les commandes de bannissement (!ban, !unban)</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="moderation_kick_enable" {% if configuration.getValue('moderation_kick_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">Activer la commande d'expulsion (!kick)</span>
</label>
</div>
<div>
<label for="moderation_log_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal de logs de modération</label>
<select name="moderation_log_channel_id" id="moderation_log_channel_id"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all">
{% for channel in channels %}
<option value="{{channel.id}}" {% if configuration.getIntValue('moderation_log_channel_id')==channel.id %}selected{% endif %}>{{channel.name}}</option>
{% endfor %}
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Toutes les actions de modération seront notifiées dans ce canal</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rôles Staff autorisés</label>
{% if roles %}
<input type="hidden" name="moderation_roles_in_form" value="1">
{% endif %}
{% set selected_roles = (configuration.getValue('moderation_staff_role_ids') or '').split(',') %} {% set selected_roles = (configuration.getValue('moderation_staff_role_ids') or '').split(',') %}
{% if roles|length > 1 %} {% if roles|length > 1 %}
<div class="tabs"> <div class="flex flex-wrap gap-1 border-b border-gray-200 dark:border-gray-600 mb-3">
{% for guild_data in roles %} {% for guild_data in roles %}
<button type="button" class="tab-button" onclick="openTab(event, 'guild-{{guild_data.guild_id}}')" {% if loop.first %}id="defaultOpen"{% endif %}> <button type="button" class="tab-button px-4 py-2 text-sm font-medium rounded-t-lg transition-colors
{% if loop.first %}bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-white{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600{% endif %}"
onclick="openTab(event, 'guild-{{guild_data.guild_id}}')" {% if loop.first %}id="defaultOpen"{% endif %}>
{{ guild_data.guild_name }} {{ guild_data.guild_name }}
</button> </button>
{% endfor %} {% endfor %}
@@ -114,127 +261,176 @@
{% endif %} {% endif %}
{% for guild_data in roles %} {% for guild_data in roles %}
<div id="guild-{{guild_data.guild_id}}" class="tab-content" {% if not loop.first %}style="display: none;"{% endif %}> <div id="guild-{{guild_data.guild_id}}" class="tab-content {% if not loop.first %}hidden{% endif %}">
<div style="max-height: 300px; overflow-y: auto; border: 1px solid #ccc; padding: 10px; border-radius: 5px;"> <div class="max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-600 rounded-lg p-3 space-y-2">
{% for role in guild_data.roles %} {% for role in guild_data.roles %}
<label style="display: block; margin: 5px 0;"> <label class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 p-1 rounded">
<input type="checkbox" name="moderation_staff_role_ids" value="{{role.id}}" {% if role.id|string in selected_roles %}checked="checked"{% endif %}> <input type="checkbox" name="moderation_staff_role_ids" value="{{role.id}}"
{% if role.id|string in selected_roles %}checked{% endif %}
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500 dark:bg-gray-700">
{% if role.color.value != 0 %} {% if role.color.value != 0 %}
<span style="color:#{{ '%06x' % role.color.value }}"></span> <span style="color:#{{ '%06x' % role.color.value }}"></span>
{% else %} {% else %}
<span></span> <span class="text-gray-400"></span>
{% endif %} {% endif %}
{{role.name}} <span class="text-sm text-gray-700 dark:text-gray-300">{{role.name}}</span>
</label> </label>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">Sélectionnez les rôles qui peuvent utiliser les commandes de modération</p>
</div>
<small>Sélectionnez un ou plusieurs rôles qui peuvent utiliser les commandes de modération</small> <div>
<label for="moderation_embed_delete_delay" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Délai de suppression des embeds (secondes)</label>
<input name="moderation_embed_delete_delay" id="moderation_embed_delete_delay" type="number" min="0"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
value="{{ configuration.getValue('moderation_embed_delete_delay') or '0' }}" placeholder="0"/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Mettre 0 pour ne pas supprimer automatiquement</p>
</div>
</div>
<script> <button type="submit"
function openTab(evt, tabName) { class="px-6 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
var i, tabcontent, tabbuttons; Enregistrer la configuration Discord
tabcontent = document.getElementsByClassName("tab-content"); </button>
for (i = 0; i < tabcontent.length; i++) { </form>
tabcontent[i].style.display = "none";
}
tabbuttons = document.getElementsByClassName("tab-button");
for (i = 0; i < tabbuttons.length; i++) {
tabbuttons[i].className = tabbuttons[i].className.replace(" active", "");
}
document.getElementById(tabName).style.display = "block";
evt.currentTarget.className += " active";
}
document.getElementById("defaultOpen")?.click();
</script>
<style> <form action="{{ url_for('publishRulesMessage') }}" method="POST" class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
.tabs { <p class="text-sm text-gray-600 dark:text-gray-400 mb-3">Envoie ou remplace le message du règlement sur Discord (utilise la config <strong>enregistrée</strong> ci-dessus).</p>
overflow: hidden; <button type="submit"
border-bottom: 2px solid #ccc; class="px-6 py-2.5 bg-teal-600 hover:bg-teal-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
margin-bottom: 10px; Publier le message règlement sur Discord
} </button>
.tab-button { </form>
background-color: #f1f1f1; </div>
border: none;
outline: none;
cursor: pointer;
padding: 10px 20px;
transition: 0.3s;
font-size: 14px;
margin-right: 2px;
}
.tab-button:hover {
background-color: #ddd;
}
.tab-button.active {
background-color: #ccc;
font-weight: bold;
}
.tab-content {
animation: fadeEffect 0.3s;
}
@keyframes fadeEffect {
from {opacity: 0;}
to {opacity: 1;}
}
</style>
<label for="moderation_embed_delete_delay">Délai de suppression des embeds (en secondes)</label> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<input name="moderation_embed_delete_delay" type="number" value="{{ configuration.getValue('moderation_embed_delete_delay') or '0' }}" placeholder="0" min="0" /> <div class="flex items-center gap-3 mb-6">
<small>Mettre 0 pour ne pas supprimer automatiquement</small> <svg class="w-6 h-6 text-purple-500" fill="currentColor" viewBox="0 0 24 24"><path d="M11.571 4.714h1.715v5.143H11.57l-.002-5.143zm3.43 0H16.714v5.143H15V4.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0H6zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714v9.429z"/></svg>
</fieldset> <h2 class="text-xl font-semibold text-gray-900 dark:text-white">API Twitch</h2>
</div>
<input type="Submit" value="Enregistrer la configuration Discord"> <form action="{{ url_for('updateConfiguration') }}" method="POST" class="space-y-4">
</form> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="twitch_client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client ID</label>
<input name="twitch_client_id" id="twitch_client_id" type="text"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
value="{{ configuration.getValue('twitch_client_id') }}"/>
</div>
<div>
<label for="twitch_client_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client Secret</label>
<input name="twitch_client_secret" id="twitch_client_secret" type="text"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
value="{{ configuration.getValue('twitch_client_secret') }}"/>
</div>
</div>
<div>
<label for="twitch_channel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Chaîne à rejoindre</label>
<input name="twitch_channel" id="twitch_channel" type="text"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
placeholder="#machinTruc"
value="{{ configuration.getValue('twitch_channel') }}"/>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Fonctionnalités du bot Twitch</h3>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="twitch_commands_enable" {% if configuration.getValue('twitch_commands_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">Activer les commandes personnalisées (!commande)</span>
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 ml-7">Les commandes configurées dans la page "Commandes" seront actives dans le chat Twitch</p>
</div>
<div class="flex items-center gap-4">
<button type="submit"
class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Enregistrer la configuration Twitch
</button>
<a href="{{ url_for('twitchConfigurationHelp') }}" class="text-purple-600 dark:text-purple-400 hover:underline text-sm">Aide</a>
</div>
<h2>API Twitch</h2>
<form action="{{ url_for('updateConfiguration') }}" method="POST">
<label for="twitch_client_id">Client ID</label>
<input name="twitch_client_id" type="text" value="{{ configuration.getValue('twitch_client_id') }}" />
<label for="twitch_client_secret">Client Secret</label>
<input name="twitch_client_secret" type="text" value="{{ configuration.getValue('twitch_client_secret') }}" />
<label for="twitch_channel">Chaîne à rejoindre</label>
<input name="twitch_channel" type="text" value="{{ configuration.getValue('twitch_channel') }}"
placeholder="#machinTruc" />
<input type="Submit" value="Enregistrer la configuration Twitch">
<p>
<a href="{{ url_for('twitchConfigurationHelp') }}">Aide</a>
</p>
{% if configuration.getValue('twitch_client_secret') and configuration.getValue('twitch_client_id') %} {% if configuration.getValue('twitch_client_secret') and configuration.getValue('twitch_client_id') %}
<p> <div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<a href="{{ url_for('twitchRequestToken') }}">Obtenir token et refresh token</a> <a href="{{ url_for('twitchRequestToken') }}"
</p> class="inline-flex items-center gap-2 px-4 py-2 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded-lg hover:bg-purple-200 dark:hover:bg-purple-900/50 transition-colors text-sm font-medium">
<label for="twitch_access_token">Access Token</label> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path></svg>
<input name="twitch_access_token" type="text" value="{{ configuration.getValue('twitch_access_token') }}" Obtenir token et refresh token
readonly="readonly" /> </a>
<label for="twitch_refresh_token">Refresh Token</label>
<input name="twitch_refresh_token" type="text" value="{{ configuration.getValue('twitch_refresh_token') }}" <div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
readonly="readonly" /> <div>
<p>Nécessite un redémarrage après l'obtention des Tokens.</p> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Access Token</label>
<input type="text" readonly
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-600 text-gray-700 dark:text-gray-300"
value="{{ configuration.getValue('twitch_access_token') }}"/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Refresh Token</label>
<input type="text" readonly
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-600 text-gray-700 dark:text-gray-300"
value="{{ configuration.getValue('twitch_refresh_token') }}"/>
</div>
</div>
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">Nécessite un redémarrage après l'obtention des Tokens.</p>
</div>
{% endif %} {% endif %}
</form> </form>
</div>
<h2>Humble Bundle</h2> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<form action="{{ url_for('updateConfiguration') }}" method="POST"> <div class="flex items-center gap-3 mb-6">
<p>Humble Bundle propose régulièrement des bundles de jeux vidéo à des prix réduits. Activez les notifications pour recevoir automatiquement les nouveaux packs disponibles sur votre serveur Discord.</p> <svg class="w-6 h-6 text-orange-500" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Humble Bundle</h2>
</div>
<label for="humble_bundle_enable"> <form action="{{ url_for('updateConfiguration') }}" method="POST" class="space-y-4">
<input type="checkbox" name="humble_bundle_enable" {% if configuration.getValue('humble_bundle_enable') %}checked="checked"{% endif %}> <div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
Activer les notifications Humble Bundle <p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Humble Bundle propose régulièrement des bundles de jeux vidéo à des prix réduits. Activez les notifications pour recevoir automatiquement les nouveaux packs disponibles sur votre serveur Discord.
</p>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="humble_bundle_enable" {% if configuration.getValue('humble_bundle_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-orange-600 focus:ring-orange-500 dark:bg-gray-700">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Activer les notifications Humble Bundle</span>
</label> </label>
</div>
<label for="humble_bundle_channel">Canal de notification</label> <div>
<select name="humble_bundle_channel"> <label for="humble_bundle_channel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal de notification</label>
<select name="humble_bundle_channel" id="humble_bundle_channel"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all">
{% for channel in channels %} {% for channel in channels %}
<option value="{{channel.id}}" {% if configuration.getIntValue('humble_bundle_channel')==channel.id %}selected="selected"{% endif %}> <option value="{{channel.id}}" {% if configuration.getIntValue('humble_bundle_channel')==channel.id %}selected{% endif %}>{{channel.name}}</option>
{{channel.name}}
</option>
{% endfor %} {% endfor %}
</select> </select>
</div>
<input type="Submit" value="Enregistrer la configuration Humble Bundle"> <button type="submit"
</form> class="px-6 py-2.5 bg-orange-600 hover:bg-orange-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Enregistrer la configuration Humble Bundle
</button>
</form>
</div>
</div>
<script>
function openTab(evt, tabName) {
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
document.querySelectorAll('.tab-button').forEach(el => {
el.classList.remove('bg-gray-200', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white');
el.classList.add('bg-gray-100', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
});
document.getElementById(tabName).classList.remove('hidden');
evt.currentTarget.classList.remove('bg-gray-100', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
evt.currentTarget.classList.add('bg-gray-200', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white');
}
document.getElementById("defaultOpen")?.click();
</script>
{% endblock %} {% endblock %}
+224
View File
@@ -0,0 +1,224 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">FreeLoot — Jeux gratuits</h1>
{% if request.args.get('msg') %}
{% set msg_type = request.args.get('type') %}
<div class="mb-4 p-4 rounded-lg {% if msg_type == 'success' %}bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-300{% elif msg_type == 'error' %}bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300{% else %}bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300{% endif %}">
{{ request.args.get('msg') }}
</div>
{% endif %}
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<p class="text-gray-700 dark:text-gray-300">
Notifications des jeux gratuits (Epic Games, Amazon Prime, GOG, Steam, Google Play, Apple App Store) via les flux
<a href="https://feed.eikowagenknecht.com/lootscraper.xml" target="_blank" rel="noopener" class="text-amber-700 dark:text-amber-300 hover:underline">LootScraper</a>.
Choisissez le canal Discord et les types de loot à notifier (PC, Android, iOS selon la source). Le bot vérifie le flux environ toutes les 30 minutes.
</p>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center gap-3 mb-6">
<span class="text-3xl">🎁</span>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Configuration FreeLoot</h2>
</div>
<form action="{{ url_for('updateFreeLoot') }}" method="POST" class="space-y-6">
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="freeloot_enable" {% if configuration.getValue('freeloot_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Activer les notifications FreeLoot</span>
</label>
</div>
<div>
<label for="freeloot_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal Discord pour les notifications</label>
<select name="freeloot_channel_id" id="freeloot_channel_id"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-amber-500 focus:border-transparent transition-all">
<option value="">— Choisir un canal —</option>
{% for channel in channels %}
<option value="{{ channel.id }}" {% if configuration.getIntValue('freeloot_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }}</option>
{% endfor %}
</select>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Mentions (optionnel)</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Choisissez qui mentionner au début du message (avant lembed).</p>
<div class="flex flex-wrap gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="freeloot_mention_everyone" {% if mention_everyone %}checked{% endif %}
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">@everyone</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="freeloot_mention_here" {% if mention_here %}checked{% endif %}
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">@here</span>
</label>
</div>
{% if roles %}
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rôles à mentionner</p>
{% if roles|length > 1 %}
<div class="flex flex-wrap gap-1 border-b border-gray-200 dark:border-gray-600 mb-3">
{% for guild_data in roles %}
<button type="button" class="freeloot-role-tab px-4 py-2 text-sm font-medium rounded-t-lg transition-colors {% if loop.first %}bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-white{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600{% endif %}"
data-tab="freeloot-roles-{{ guild_data.guild_id }}" {% if loop.first %}data-default{% endif %}>
{{ guild_data.guild_name }}
</button>
{% endfor %}
</div>
{% endif %}
{% for guild_data in roles %}
<div id="freeloot-roles-{{ guild_data.guild_id }}" class="freeloot-role-panel {% if not loop.first %}hidden{% endif %} max-h-48 overflow-y-auto border border-gray-200 dark:border-gray-600 rounded-lg p-3 space-y-2">
{% for role in guild_data.roles %}
<label class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600/50 p-1 rounded">
<input type="checkbox" name="freeloot_mention_roles" value="{{ role.id }}"
{% if role.id|string in mention_role_ids %}checked{% endif %}
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
{% if role.color is defined and role.color is not none and role.color.value != 0 %}
<span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color:#{{ '%06x'|format(role.color.value) }}"></span>
{% else %}
<span class="w-3 h-3 rounded-full flex-shrink-0 bg-gray-400"></span>
{% endif %}
<span class="text-sm text-gray-700 dark:text-gray-300">{{ role.name }}</span>
</label>
{% endfor %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Types de loot à notifier</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Cochez les sources et plateformes pour lesquelles vous voulez recevoir une notification.</p>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{% for key, label, emoji in sources %}
<label class="flex items-center gap-2 cursor-pointer p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600/50 transition-colors">
<input type="checkbox" name="freeloot_sources" value="{{ key }}"
{% if not enabled_sources or key in enabled_sources %}checked{% endif %}
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-amber-600 focus:ring-amber-500 dark:bg-gray-700">
<span class="text-lg" title="{{ label }}">{{ emoji }}</span>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ label }}</span>
</label>
{% endfor %}
</div>
</div>
<button type="submit"
class="px-6 py-2.5 bg-amber-600 hover:bg-amber-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Enregistrer
</button>
</form>
<div class="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
<h3 class="font-medium text-gray-800 dark:text-gray-200 mb-3">Aperçu de lembed Discord (style DraftBot)</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Exemple du message envoyé dans le canal.</p>
<div class="inline-block rounded-r-lg overflow-hidden border border-gray-300 dark:border-gray-600 bg-[#2f3136] max-w-lg shadow-lg" style="border-left: 4px solid #E67E22;">
<div class="p-4">
<div class="flex items-start gap-2 mb-2">
<a href="#" class="text-[#00a8fc] hover:underline font-semibold text-base flex-1">Definitely Not Fried Chicken gratuit sur l'Epic Games Store !</a>
<img src="https://store.epicgames.com/favicon.ico" alt="" class="w-10 h-10 rounded flex-shrink-0" title="Logo boutique">
</div>
<p class="text-[#dcddde] text-sm leading-relaxed mb-3">Definitely Not Fried Chicken is a business management sim with a twist! Grow your drugs trade through legitimate fronts, managing both sides of the business. Acquire new "businesses", meet new clientele, develop more potent narcotics…</p>
<div class="text-sm mb-2">
<span class="text-[#b9bbbe]">Prix</span>
<p class="text-[#dcddde]"><strong>Gratuit</strong> • jusqu'au 05/02/2026</p>
</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm mb-2">
<div><span class="text-[#b9bbbe]">Prix recommandé</span><p class="text-[#dcddde]">39.99 EUR</p></div>
<div><span class="text-[#b9bbbe]">Genres</span><p class="text-[#dcddde]">Simulation, Indie</p></div>
<div><span class="text-[#b9bbbe]">Ratings</span><p class="text-[#dcddde]">PEGI 18, USK 18</p></div>
</div>
<p class="mb-3"><a href="#" class="text-[#00a8fc] hover:underline text-sm">Ouvrir dans la boutique !</a></p>
<div class="rounded overflow-hidden bg-[#202225] aspect-video flex items-center justify-center my-2">
<span class="text-4xl text-gray-500">🎁</span>
</div>
<p class="text-xs text-[#72767d] pt-1">MamieHenriette • FreeLoot</p>
</div>
</div>
</div>
</div>
<script>
document.querySelectorAll('.freeloot-role-tab').forEach(btn => {
btn.addEventListener('click', function() {
var tabId = this.getAttribute('data-tab');
document.querySelectorAll('.freeloot-role-panel').forEach(p => p.classList.add('hidden'));
document.querySelectorAll('.freeloot-role-tab').forEach(b => {
b.classList.remove('bg-gray-200', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white');
b.classList.add('bg-gray-100', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
});
document.getElementById(tabId).classList.remove('hidden');
this.classList.remove('bg-gray-100', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
this.classList.add('bg-gray-200', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white');
});
});
document.querySelector('.freeloot-role-tab[data-default]')?.click();
</script>
{% if entries %}
<div class="mt-8">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Jeux gratuits actuellement disponibles</h2>
</div>
<div class="p-6 overflow-x-auto">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{% for e in entries %}
<article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden hover:shadow-md transition-shadow flex flex-col">
<div class="aspect-video bg-gray-100 dark:bg-gray-700 flex items-center justify-center overflow-hidden">
{% if e.image_url %}
<img src="{{ e.image_url }}" alt="" class="w-full h-full object-cover" loading="lazy" />
{% else %}
<span class="text-4xl text-gray-400 dark:text-gray-500">🎁</span>
{% endif %}
</div>
<div class="p-4 flex flex-col flex-1 border-t border-gray-200 dark:border-gray-700">
<h3 class="font-semibold text-gray-900 dark:text-white mb-1 line-clamp-2" title="{{ e.game_name }}">{{ e.game_name }}</h3>
<p class="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 mb-2">
<span>{{ e.emoji }}</span>
<span>{{ e.source_label }}</span>
</p>
{% if e.recommended_price or e.genres or e.rating %}
<div class="text-xs text-gray-500 dark:text-gray-400 space-y-0.5 mb-2">
{% if e.recommended_price %}<p><span class="text-gray-500 dark:text-gray-500">Prix recommandé:</span> {{ e.recommended_price }}</p>{% endif %}
{% if e.genres %}<p><span class="text-gray-500 dark:text-gray-500">Genres:</span> {{ e.genres }}</p>{% endif %}
{% if e.rating %}<p><span class="text-gray-500 dark:text-gray-500">Ratings:</span> {{ e.rating }}</p>{% endif %}
</div>
{% endif %}
{% if e.updated_formatted %}
<p class="text-xs text-gray-500 dark:text-gray-500 mb-3">{{ e.updated_formatted }}</p>
{% endif %}
<div class="mt-auto flex flex-col gap-2">
<form action="{{ url_for('send_free_loot_to_discord') }}" method="POST" class="w-full">
<input type="hidden" name="entry_id" value="{{ e.id }}">
<button type="submit" class="w-full inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white transition-colors focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/></svg>
Envoyer sur Discord
</button>
</form>
{% if e.link %}
<a href="{{ e.link }}" target="_blank" rel="noopener noreferrer" class="inline-flex items-center justify-center gap-2 w-full px-3 py-2 text-sm font-medium rounded-lg bg-amber-600 hover:bg-amber-700 text-white transition-colors focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Ouvrir dans la boutique
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>
</a>
{% endif %}
</div>
</div>
</article>
{% endfor %}
</div>
</div>
</div>
</div>
{% else %}
<div class="mt-8 p-4 rounded-lg bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700">
<p class="text-gray-600 dark:text-gray-400 text-sm">Le flux LootScraper na pas pu être chargé. Réessayez plus tard.</p>
</div>
{% endif %}
{% endblock %}
+56 -16
View File
@@ -1,29 +1,69 @@
{% extends "template.html" %} {% extends "template.html" %}
{% block content %} {% block content %}
<h1>Humeurs de Mamie</h1> <div class="mb-8">
<p>Définissez les statuts Discord qui changeront automatiquement toutes les 10 minutes pour donner de la personnalité à votre bot.</p> <h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Humeurs de Mamie</h1>
<table> <div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<thead> <p class="text-gray-700 dark:text-gray-300">
Définissez les statuts Discord qui changeront automatiquement toutes les 10 minutes pour donner de la personnalité à votre bot.
</p>
</div>
</div>
<div class="mb-8">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Liste des humeurs</h2>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr> <tr>
<th>Texte</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Texte</th>
<th>Action</th> <th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-24">Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for humeur in humeurs %} {% for humeur in humeurs %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td class="px-6 py-4 text-gray-700 dark:text-gray-300">{{ humeur.text }}</td>
<td class="px-6 py-4 text-center">
<a href="{{ url_for('delHumeur', id = humeur.id) }}"
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette humeur ?')"
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors text-red-600 dark:text-red-400 inline-block"
title="Supprimer">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
</a>
</td>
</tr>
{% else %}
<tr> <tr>
<td>{{humeur.text}}</td> <td colspan="2" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
<td><a href="{{ url_for('delHumeur', id = humeur.id) }}" onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette humeur ?')">Supprimer</a></td> Aucune humeur configurée. Ajoutez-en une ci-dessous.
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
</div>
</div>
<h2>Ajouter une humeur</h2> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<form action="{{ url_for('addHumeur') }}" method="POST"> <h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Ajouter une humeur</h2>
<label for="text">Texte</label>
<input name="text" type="text" /> <form action="{{ url_for('addHumeur') }}" method="POST" class="space-y-6">
<input type="Submit" value="Ajouter"> <div>
</form> <label for="text" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Texte du statut</label>
<input name="text" id="text" type="text" required
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
placeholder="Joue à un jeu vidéo..."/>
</div>
<div>
<button type="submit"
class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Ajouter l'humeur
</button>
</div>
</form>
</div>
{% endblock %} {% endblock %}
+98 -3
View File
@@ -1,7 +1,102 @@
{% extends "template.html" %} {% extends "template.html" %}
{% block content %} {% block content %}
<h1>Bienvenue sur l'interface d'administration de Mamie.</h1> <div class="text-center py-10">
<p>Nous devons définir ce que nous souhaitons afficher sur la page d'accueil. Peut-être l'historique des dernières <h1 class="text-3xl sm:text-4xl font-bold text-slate-800 dark:text-white mb-3">
modifications ? de la modération ?</p> Panneau d'administration
</h1>
<p class="text-base text-slate-600 dark:text-slate-400 max-w-xl mx-auto">
Gérez les fonctionnalités de votre bot Discord et Twitch depuis cette interface.
</p>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden mb-8">
<div class="p-4 sm:p-6 border-b border-slate-200 dark:border-slate-700 flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-3">
<span class="inline-flex items-center justify-center w-3 h-3 rounded-full {% if discord_connected %}bg-emerald-500 ring-4 ring-emerald-500/30{% else %}bg-slate-400 ring-4 ring-slate-400/30{% endif %}" title="{% if discord_connected %}Bot Discord connecté{% else %}Bot Discord déconnecté{% endif %}"></span>
<h2 class="text-xl font-semibold text-slate-800 dark:text-white">Discord</h2>
</div>
<span class="text-sm text-slate-500 dark:text-slate-400">
{% if discord_connected %}Connecté{% else %}Déconnecté{% endif %}
</span>
</div>
<div class="p-4 sm:p-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600">
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">Serveurs connectés</p>
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">{{ discord_guild_count }}</p>
</div>
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600">
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">Sanctions enregistrées</p>
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">{{ sanctions_count }}</p>
</div>
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600 sm:col-span-2 lg:col-span-2 flex items-center justify-center">
<div class="flex flex-wrap gap-3 justify-center">
<a href="/live-alert" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Alertes Live</a>
<a href="/youtube" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Notification YouTube</a>
<a href="/humeurs" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Humeurs</a>
<a href="/protondb" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">ProtonDB</a>
<a href="/commandes" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Commandes</a>
<a href="/moderation" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Modération</a>
</div>
</div>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden mb-8">
<div class="p-4 sm:p-6 border-b border-slate-200 dark:border-slate-700 flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-3">
<span class="inline-flex items-center justify-center w-3 h-3 rounded-full {% if twitch_connected %}bg-emerald-500 ring-4 ring-emerald-500/30{% else %}bg-slate-400 ring-4 ring-slate-400/30{% endif %}" title="{% if twitch_connected %}Bot Twitch connecté{% else %}Bot Twitch déconnecté{% endif %}"></span>
<h2 class="text-xl font-semibold text-slate-800 dark:text-white">Twitch</h2>
</div>
<span class="text-sm text-slate-500 dark:text-slate-400">
{% if twitch_connected %}Connecté{% else %}Déconnecté{% endif %}
</span>
</div>
<div class="p-4 sm:p-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600">
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">Canal connecté</p>
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">{% if twitch_channel_name %}{{ twitch_channel_name }}{% else %}—{% endif %}</p>
</div>
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600">
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">Annonces configurées</p>
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">{{ twitch_announcements_count }}</p>
</div>
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600">
<p class="text-sm font-medium text-slate-500 dark:text-slate-400">Actions de modération</p>
<p class="text-2xl font-bold text-slate-800 dark:text-white mt-1">{{ twitch_moderation_count }}</p>
</div>
<div class="rounded-lg bg-slate-50 dark:bg-slate-700/50 p-4 border border-slate-200 dark:border-slate-600 flex items-center justify-center">
<div class="flex flex-wrap gap-3 justify-center">
<a href="/announcements" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-600 text-white text-sm font-medium hover:bg-purple-700 transition-colors">Annonces</a>
<a href="/twitch-moderation" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Modération</a>
<a href="/link-filter" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 transition-colors">Filtre de liens</a>
</div>
</div>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg p-6 border border-slate-200 dark:border-slate-700">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
<svg class="w-5 h-5 text-slate-600 dark:text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
</div>
<div>
<h3 class="font-medium text-slate-800 dark:text-white mb-2">À propos</h3>
<p class="text-sm text-slate-600 dark:text-slate-400 mb-4">
Mamie Henriette est un bot open source pour Discord et Twitch, développé par la communauté.
Cette interface vous permet de configurer et gérer toutes les fonctionnalités.
</p>
<div class="flex flex-wrap gap-3">
<a href="https://github.com/skylanix/MamieHenriette" target="_blank" class="inline-flex items-center gap-2 px-3 py-1.5 bg-slate-800 dark:bg-slate-700 text-white rounded text-sm hover:bg-slate-700 dark:hover:bg-slate-600 transition-colors">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"></path></svg>
GitHub
</a>
<a href="https://discord.com/invite/UwAPqMJnx3" target="_blank" class="inline-flex items-center gap-2 px-3 py-1.5 bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 border border-slate-200 dark:border-slate-600 rounded text-sm hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"></path></svg>
Discord
</a>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
+176
View File
@@ -0,0 +1,176 @@
{% extends "template.html" %}
{% block content %}
<div class="space-y-6">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">Filtre de liens</h1>
<p class="text-gray-600 dark:text-gray-400">Bloquez les liens non autorises sur votre chat Twitch.</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="p-3 rounded-lg {{ 'bg-green-100 dark:bg-green-900/30' if config.enabled else 'bg-gray-100 dark:bg-gray-700' }}">
<svg class="w-6 h-6 {{ 'text-green-600 dark:text-green-400' if config.enabled else 'text-gray-500' }}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Protection des liens</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ 'Active' if config.enabled else 'Desactive' }}</p>
</div>
</div>
<a href="{{ url_for('toggle_link_filter') }}" class="px-4 py-2 rounded-lg font-medium transition-colors {{ 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400' if config.enabled else 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' }}">
{{ 'Desactiver' if config.enabled else 'Activer' }}
</a>
</div>
<form action="{{ url_for('update_link_filter') }}" method="POST" class="space-y-6">
<div class="grid md:grid-cols-2 gap-6">
<div class="space-y-4">
<h3 class="font-medium text-gray-900 dark:text-white">Autoriser les liens pour</h3>
<label class="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input type="checkbox" name="allow_moderators" {{ 'checked' if config.allow_moderators }} class="rounded border-gray-300 text-purple-600 focus:ring-purple-500">
<div>
<span class="text-gray-900 dark:text-white font-medium">Moderateurs</span>
<p class="text-xs text-gray-500 dark:text-gray-400">Les modos peuvent toujours poster des liens</p>
</div>
</label>
<label class="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input type="checkbox" name="allow_vips" {{ 'checked' if config.allow_vips }} class="rounded border-gray-300 text-purple-600 focus:ring-purple-500">
<div>
<span class="text-gray-900 dark:text-white font-medium">VIP</span>
<p class="text-xs text-gray-500 dark:text-gray-400">Les VIP peuvent poster des liens</p>
</div>
</label>
<label class="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input type="checkbox" name="allow_subscribers" {{ 'checked' if config.allow_subscribers }} class="rounded border-gray-300 text-purple-600 focus:ring-purple-500">
<div>
<span class="text-gray-900 dark:text-white font-medium">Abonnes</span>
<p class="text-xs text-gray-500 dark:text-gray-400">Les abonnes peuvent poster des liens</p>
</div>
</label>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Duree du timeout (secondes)</label>
<input type="number" name="timeout_duration" value="{{ config.timeout_duration }}" min="0" max="1209600"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">0 = pas de timeout, juste suppression du message</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Message d'avertissement</label>
<textarea name="warning_message" rows="2"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-none">{{ config.warning_message }}</textarea>
</div>
</div>
</div>
<button type="submit" class="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors font-medium">
Enregistrer les parametres
</button>
</form>
</div>
<div class="grid md:grid-cols-2 gap-6">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<h3 class="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path>
</svg>
Domaines autorises ({{ domains|length }})
</h3>
</div>
<form action="{{ url_for('add_allowed_domain') }}" method="POST" class="p-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex gap-2">
<input type="text" name="domain" placeholder="exemple.com" required
class="flex-1 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm">
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors text-sm font-medium">
Ajouter
</button>
</div>
</form>
{% if domains %}
<div class="max-h-48 overflow-y-auto">
<div class="divide-y divide-gray-200 dark:divide-gray-700">
{% for domain in domains %}
<div class="px-4 py-2 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50">
<code class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ domain.domain }}</code>
<a href="{{ url_for('delete_allowed_domain', domain_id=domain.id) }}" class="text-red-600 hover:text-red-700 text-sm">Supprimer</a>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="px-4 py-6 text-center text-gray-500 dark:text-gray-400 text-sm">
Aucun domaine autorise
</div>
{% endif %}
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<h3 class="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
Viewers autorises ({{ users|length }})
</h3>
</div>
<form action="{{ url_for('add_allowed_user') }}" method="POST" class="p-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex gap-2">
<input type="text" name="username" placeholder="@pseudo" required
class="flex-1 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm">
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors text-sm font-medium">
Ajouter
</button>
</div>
</form>
{% if users %}
<div class="max-h-48 overflow-y-auto">
<div class="divide-y divide-gray-200 dark:divide-gray-700">
{% for user in users %}
<div class="px-4 py-2 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50">
<span class="text-sm text-gray-700 dark:text-gray-300">@{{ user.username }}</span>
<a href="{{ url_for('delete_allowed_user', user_id=user.id) }}" class="text-red-600 hover:text-red-700 text-sm">Supprimer</a>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="px-4 py-6 text-center text-gray-500 dark:text-gray-400 text-sm">
Aucun viewer en liste blanche
</div>
{% endif %}
</div>
</div>
<div class="bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-900/20 dark:to-purple-900/20 rounded-xl p-6 border border-blue-200 dark:border-blue-800">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
Commande Permit
</h3>
<p class="text-gray-600 dark:text-gray-400 text-sm mb-4">
Utilisez la commande <code class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-xs">!permit</code> pour autoriser temporairement un viewer a poster un lien.
</p>
<div class="flex flex-wrap gap-4 text-sm">
<div class="flex items-center gap-2">
<code class="px-2 py-1 bg-white dark:bg-gray-800 rounded text-xs font-mono text-purple-600 dark:text-purple-400">!permit @viewer</code>
<span class="text-gray-600 dark:text-gray-400">Autorise 1 min</span>
</div>
<div class="flex items-center gap-2">
<code class="px-2 py-1 bg-white dark:bg-gray-800 rounded text-xs font-mono text-purple-600 dark:text-purple-400">!permit @viewer 5</code>
<span class="text-gray-600 dark:text-gray-400">Autorise 5 min</span>
</div>
</div>
</div>
</div>
{% endblock %}
+313 -54
View File
@@ -1,76 +1,335 @@
{% extends "template.html" %} {% extends "template.html" %}
{% block content %} {% block content %}
<h1>Alerte Live</h1> <div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Alerte Live</h1>
<p> <div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
Liste des chaines surveillées pour les alertes de live twitch. <p class="text-gray-700 dark:text-gray-300">
Liste des chaînes surveillées pour les alertes de live Twitch.
Le bot vérifie toutes les 5 minutes qui est en live dans la liste en dessous. Le bot vérifie toutes les 5 minutes qui est en live dans la liste en dessous.
Le bot enregistre le status de stream toutes les 5 minutes, quand le status pass de "hors-ligne" à "en ligne" alors Le bot enregistre le status de stream toutes les 5 minutes, quand le status passe de "hors-ligne" à "en ligne" alors
le bot le notifiera sur discord. le bot enverra une notification (embed Discord) sur le canal choisi.
Ne peu surveiller qu'au maximum 100 chaines. <span class="font-medium text-blue-600 dark:text-blue-400">Ne peut surveiller qu'au maximum 100 chaînes.</span>
</p> </p>
</div>
</div>
{% if not alert %} {% if not alert %}
<h2>Alertes</h2> <div class="mb-8">
<table class="live-alert"> <h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Alertes configurées</h2>
<thead> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr> <tr>
<th>Chaine</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Chaîne</th>
<th>Canal</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Canal</th>
<th>Message</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Message / Embed</th>
<th>#</th> <th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider" title="Le bot affichera 'Regarde [streamer]' comme activité">Activité</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for alert in alerts %} {% for alert in alerts %}
<tr> <tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td>{{alert.login}}</td> <td class="px-6 py-4 whitespace-nowrap">
<td>{{alert.notify_channel_name}}</td> <a href="https://www.twitch.tv/{{alert.login}}" target="_blank" class="text-purple-600 dark:text-purple-400 hover:underline font-medium">{{alert.login}}</a>
<td>{{alert.message}}</td> </td>
<td> <td class="px-6 py-4 whitespace-nowrap text-gray-700 dark:text-gray-300">{{alert.notify_channel_name}}</td>
<a href="{{ url_for('toggleLiveAlert', id = alert.id) }}" class="icon">{{ '✅' if alert.enable else '❌' }}</a> <td class="px-6 py-4 text-gray-600 dark:text-gray-400 max-w-md truncate">{{alert.message or '(embed)'}}</td>
<a href="{{ url_for('openEditLiveAlert', id = alert.id) }}" class="icon"></a> <td class="px-6 py-4 whitespace-nowrap text-center">
<a href="{{ url_for('toggleWatchActivity', id = alert.id) }}"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
title="{{ 'Désactiver l\'activité' if alert.watch_activity else 'Activer l\'activité' }}">
{{ '👁️' if alert.watch_activity else '👁️‍🗨️' }}
</a>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<div class="flex items-center justify-center gap-2">
<a href="{{ url_for('toggleLiveAlert', id = alert.id) }}"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
title="{{ 'Désactiver' if alert.enable else 'Activer' }}">
{{ '✅' if alert.enable else '❌' }}
</a>
<a href="{{ url_for('openEditLiveAlert', id = alert.id) }}"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors text-blue-600 dark:text-blue-400"
title="Modifier">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
</a>
<a href="{{ url_for('delLiveAlert', id = alert.id) }}" <a href="{{ url_for('delLiveAlert', id = alert.id) }}"
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette alerte ?')" class="icon">🗑</a> onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette alerte ?')"
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors text-red-600 dark:text-red-400"
title="Supprimer">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
</a>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
Aucune alerte configurée. Ajoutez-en une ci-dessous.
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
</div>
</div>
{% endif %} {% endif %}
<h2>{{ 'Editer une alerte' if alert else 'Ajouter une alerte de Live' }}</h2> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<form action="{{ url_for('submitEditLiveAlert', id = alert.id) if alert else url_for('addLiveAlert') }}" method="POST"> <h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
<label for="login">Chaine</label> {{ 'Modifier l\'alerte' if alert else 'Ajouter une alerte de Live' }}
<input name="login" type="text" maxlength="32" required="required" value="{{alert.login if alert}}"/> </h2>
<label for="notify_channel">Canal de Notification</label>
<select name="notify_channel"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div>
<form id="live-alert-form" action="{{ url_for('submitEditLiveAlert', id = alert.id) if alert else url_for('addLiveAlert') }}" method="POST" class="space-y-6">
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Configuration de base</h3>
<div>
<label for="login" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Chaîne Twitch</label>
<input name="login" id="login" type="text" maxlength="32" required
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
placeholder="chainesteve"
value="{{alert.login if alert}}"/>
</div>
<div>
<label for="notify_channel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal de notification Discord</label>
<select name="notify_channel" id="notify_channel"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all">
{% for channel in channels %} {% for channel in channels %}
<option value="{{channel.id}}"{% if alert and alert.notify_channel == channel.id %} <option value="{{channel.id}}" {% if alert and alert.notify_channel == channel.id %}selected{% endif %}>{{channel.name}}</option>
selected="selected" {% endif %}>{{channel.name}}</option>
{% endfor %} {% endfor %}
</select> </select>
<label for="message">Message</label> </div>
<textarea name="message" rows="5" cols="50" required="required">{{alert.message if alert}}</textarea>
<input type="Submit" value="Ajouter">
<p>
La chaine est le login de la chaine, par exemple <strong>chainesteve</strong> pour <strong>https://www.twitch.tv/chainesteve</strong>.
</p>
<p>
Pour le message vous avez acces à ces variables :
<ul>
<li>{0.user_login} : pour le lien vers la chaine</li>
<li>{0.user_name} : à priviligier pour le text</li>
<li>{0.game_name}</li>
<li>{0.title}</li>
<li>{0.language}</li>
</ul>
Le message est au format <a href="https://commonmark.org/" target="_blank">common-mark</a> dans la limite de ce que
support discord.
Pour mettre un lien vers la chaine : [description](https://www.twitch.tv/{0.user_login})
</p>
</form>
<div>
<label for="message" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Message (optionnel, avant l'embed)</label>
<textarea name="message" id="message" rows="2"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all resize-y"
placeholder="Message envoyé avant l'embed">{{alert.message if alert}}</textarea>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Variables: {user_name}, {title}, [lien](https://www.twitch.tv/{user_login})</p>
</div>
<div class="flex items-center gap-3">
<input type="checkbox" name="watch_activity" id="watch_activity" value="1"
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700"
{% if alert and alert.watch_activity %}checked{% endif %}>
<label for="watch_activity" class="text-sm font-medium text-gray-700 dark:text-gray-300">
Afficher "Regarde ce stream" comme activité du bot Discord
</label>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Personnalisation de l'embed Discord</h3>
<div>
<label for="embed_title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Titre de l'embed</label>
<input name="embed_title" id="embed_title" type="text" maxlength="256"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
placeholder="{title}"
value="{{alert.embed_title if alert else '{title}'}}"/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Variables: {title}, {user_name}, {game_name}, {stream_url}, {user_login}</p>
</div>
<div>
<label for="embed_description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description de l'embed</label>
<textarea name="embed_description" id="embed_description" rows="2"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all resize-y"
placeholder="Description optionnelle">{{alert.embed_description if alert}}</textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="embed_color" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Couleur</label>
<div class="flex items-center gap-2">
<input name="embed_color" id="embed_color" type="color"
class="w-12 h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer"
value="#{{alert.embed_color if alert else '9146FF'}}"/>
<input type="text" id="embed_color_text" maxlength="6"
class="flex-1 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
value="{{alert.embed_color if alert else '9146FF'}}" placeholder="9146FF"/>
</div>
</div>
<div>
<label for="embed_author_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Nom de l'auteur</label>
<input name="embed_author_name" id="embed_author_name" type="text" maxlength="256"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
placeholder="{user_name}"
value="{{alert.embed_author_name if alert}}"/>
</div>
</div>
<div>
<label for="embed_author_icon" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Icône de l'auteur (URL)</label>
<input name="embed_author_icon" id="embed_author_icon" type="text" maxlength="512"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
placeholder="Laissez vide pour l'avatar Twitch"
value="{{alert.embed_author_icon if alert}}"/>
</div>
<div>
<label for="embed_footer" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Pied de page</label>
<input name="embed_footer" id="embed_footer" type="text" maxlength="2048"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
placeholder="Texte optionnel en bas"
value="{{alert.embed_footer if alert}}"/>
</div>
<div class="flex flex-wrap gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="embed_thumbnail" id="embed_thumbnail"
{% if not alert or alert.embed_thumbnail %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">Miniature (preview)</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="embed_image" id="embed_image"
{% if not alert or alert.embed_image %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">Image principale</span>
</label>
</div>
</div>
<div class="flex items-center gap-4">
<button type="submit"
class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
{{ 'Enregistrer' if alert else 'Ajouter l\'alerte' }}
</button>
{% if alert %}
<a href="{{ url_for('openLiveAlert') }}"
class="px-6 py-2.5 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 font-medium rounded-lg transition-colors">
Annuler
</a>
{% endif %}
</div>
</form>
</div>
<div>
<h3 class="font-medium text-gray-800 dark:text-gray-200 mb-4">Prévisualisation de l'embed Discord</h3>
<div id="embed-preview" class="bg-[#2f3136] rounded p-4 font-sans text-[#dcddde] max-w-xl border-l-4" style="border-left-color: #9146FF;">
<div id="embed-author" class="flex items-center mb-2 text-sm">
<img id="embed-author-icon" src="https://static-cdn.jtvnw.net/ttv-favicon/favicon-32x32.png" class="w-5 h-5 rounded-full mr-2" onerror="this.style.display='none'"/>
<span id="embed-author-name" class="font-semibold">Nom du streamer</span>
</div>
<a id="embed-title" href="#" class="text-[#00aff4] no-underline text-base font-semibold block mb-2">Titre du stream</a>
<div id="embed-description" class="text-sm leading-relaxed mb-2 text-[#dcddde]"></div>
<div id="embed-thumbnail-container" class="my-2">
<img id="embed-thumbnail" src="" class="max-w-[80px] max-h-[80px] rounded float-right ml-4 hidden"/>
</div>
<div id="embed-image-container" class="mt-4">
<img id="embed-image" src="https://static-cdn.jtvnw.net/previews-ttv/live_user_chaine-320x180.jpg" class="max-w-full rounded hidden"/>
</div>
<div id="embed-footer" class="mt-2 text-xs text-[#72767d]"></div>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">Cette prévisualisation est approximative.</p>
<div class="mt-6 bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<h4 class="font-medium text-gray-800 dark:text-gray-200 mb-2">Variables disponibles (embed)</h4>
<ul class="text-sm text-gray-600 dark:text-gray-400 space-y-1">
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{user_login}</code> — Login Twitch</li>
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{user_name}</code> — Nom d'affichage</li>
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{game_name}</code> — Jeu en cours</li>
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{title}</code> — Titre du stream</li>
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{language}</code> — Langue</li>
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{stream_url}</code> — Lien Twitch</li>
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{thumbnail}</code> — URL preview</li>
</ul>
</div>
</div>
</div>
</div>
<script>
function formatText(text, vars) {
if (!text) return '';
return text.replace(/\{(\w+)\}/g, function(match, key) {
return vars[key] !== undefined && vars[key] !== null ? vars[key] : match;
});
}
function updatePreview() {
const embedTitle = document.getElementById('embed_title').value || '{title}';
const embedDescription = document.getElementById('embed_description').value || '';
const embedColor = document.getElementById('embed_color_text').value || '9146FF';
const embedAuthorName = document.getElementById('embed_author_name').value || '{user_name}';
const embedAuthorIcon = document.getElementById('embed_author_icon').value || '';
const embedFooter = document.getElementById('embed_footer').value || '';
const embedThumbnail = document.getElementById('embed_thumbnail').checked;
const embedImage = document.getElementById('embed_image').checked;
const vars = {
user_login: 'chainesteve',
user_name: 'ChaîneSteve',
game_name: 'Minecraft',
title: '🔴 Live chill avec les viewers',
language: 'fr',
stream_url: 'https://www.twitch.tv/chainesteve',
thumbnail: 'https://static-cdn.jtvnw.net/previews-ttv/live_user_chainesteve-320x180.jpg'
};
document.getElementById('embed-title').textContent = formatText(embedTitle, vars);
document.getElementById('embed-title').href = vars.stream_url;
document.getElementById('embed-description').textContent = formatText(embedDescription, vars);
document.getElementById('embed-author-name').textContent = formatText(embedAuthorName, vars);
document.getElementById('embed-author-icon').src = embedAuthorIcon || 'https://static-cdn.jtvnw.net/ttv-favicon/favicon-32x32.png';
document.getElementById('embed-author-icon').style.display = embedAuthorIcon ? '' : 'none';
document.getElementById('embed-footer').textContent = formatText(embedFooter, vars);
document.getElementById('embed-preview').style.borderLeftColor = '#' + embedColor;
if (embedThumbnail) {
document.getElementById('embed-thumbnail').src = vars.thumbnail;
document.getElementById('embed-thumbnail').style.display = 'block';
} else {
document.getElementById('embed-thumbnail').style.display = 'none';
}
if (embedImage) {
document.getElementById('embed-image').src = vars.thumbnail;
document.getElementById('embed-image').style.display = 'block';
} else {
document.getElementById('embed-image').style.display = 'none';
}
}
const colorInput = document.getElementById('embed_color');
if (colorInput) {
colorInput.addEventListener('input', function(e) {
document.getElementById('embed_color_text').value = e.target.value.substring(1).toUpperCase();
updatePreview();
});
}
const colorText = document.getElementById('embed_color_text');
if (colorText) {
colorText.addEventListener('input', function(e) {
const val = e.target.value.replace(/[^0-9A-Fa-f]/g, '').substring(0, 6);
e.target.value = val;
if (val.length === 6) {
document.getElementById('embed_color').value = '#' + val;
updatePreview();
}
});
}
const formFields = ['embed_title', 'embed_description', 'embed_author_name', 'embed_author_icon', 'embed_footer', 'embed_thumbnail', 'embed_image'];
formFields.forEach(function(field) {
const el = document.getElementById(field);
if (el) {
el.addEventListener('input', updatePreview);
el.addEventListener('change', updatePreview);
}
});
updatePreview();
</script>
{% endblock %} {% endblock %}
+35
View File
@@ -0,0 +1,35 @@
{% extends "template.html" %}
{% block content %}
<div class="max-w-md mx-auto py-12">
<h1 class="text-2xl font-bold text-slate-800 dark:text-white mb-6 text-center">Connexion</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mb-4 space-y-2">
{% for category, msg in messages %}
<p class="p-3 rounded-lg text-sm {% if category == 'error' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200{% else %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200{% endif %}">{{ msg }}</p>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="post" action="{{ url_for('login') }}" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 space-y-4">
<div>
<label for="identifier" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Identifiant (nom d'utilisateur ou e-mail)</label>
<input type="text" id="identifier" name="identifier" required autocomplete="username" class="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" placeholder="nom ou email">
</div>
<div>
<label for="password" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Mot de passe</label>
<input type="password" id="password" name="password" required autocomplete="current-password" class="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent">
</div>
<button type="submit" class="w-full py-2.5 px-4 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">Se connecter</button>
</form>
{% if registration_enabled %}
<p class="mt-4 text-center text-sm text-slate-600 dark:text-slate-400">
Pas encore de compte ? <a href="{{ url_for('register') }}" class="text-primary-600 dark:text-primary-400 hover:underline">Créer un compte</a>
</p>
{% endif %}
</div>
{% endblock %}
+202 -89
View File
@@ -1,110 +1,223 @@
{% extends "template.html" %} {% extends "template.html" %}
{% block content %} {% block content %}
<h1>Modération Discord</h1> <div class="mb-6">
<h1 class="text-2xl font-semibold text-slate-800 dark:text-white mb-1">Modération</h1>
<p class="text-sm text-slate-600 dark:text-slate-400">
Historique des actions de modération sur le serveur Discord.
</p>
</div>
<p> <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
Historique des actions de modération effectuées sur le serveur Discord. <div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<h2 class="text-lg font-medium text-slate-800 dark:text-white">Top 3 sanctions</h2>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Utilisateurs les plus sanctionnés</p>
</div>
<div class="divide-y divide-slate-200 dark:divide-slate-700">
{% for row in top_sanctioned %}
<div class="px-5 py-3 flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<span class="flex-shrink-0 w-7 h-7 rounded-full bg-slate-200 dark:bg-slate-600 flex items-center justify-center text-sm font-bold text-slate-700 dark:text-slate-300">{{ loop.index }}</span>
<div class="min-w-0">
<span class="block text-sm font-medium text-slate-800 dark:text-white truncate">{{ row.username or '—' }}</span>
<span class="block text-xs text-slate-500 dark:text-slate-400 font-mono truncate">{{ row.discord_id }}</span>
</div>
</div>
<span class="flex-shrink-0 text-sm font-semibold text-slate-600 dark:text-slate-300">{{ row.count }} sanction{{ 's' if row.count > 1 else '' }}</span>
</div>
{% else %}
<div class="px-5 py-6 text-center text-sm text-slate-500 dark:text-slate-400">Aucune sanction enregistrée</div>
{% endfor %}
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<h2 class="text-lg font-medium text-slate-800 dark:text-white">Top 3 modérateurs</h2>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Staff ayant effectué le plus d'actions</p>
</div>
<div class="divide-y divide-slate-200 dark:divide-slate-700">
{% for row in top_moderators %}
<div class="px-5 py-3 flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<span class="flex-shrink-0 w-7 h-7 rounded-full bg-slate-200 dark:bg-slate-600 flex items-center justify-center text-sm font-bold text-slate-700 dark:text-slate-300">{{ loop.index }}</span>
<div class="min-w-0">
<span class="block text-sm font-medium text-slate-800 dark:text-white truncate">{{ row.staff_name or '—' }}</span>
<span class="block text-xs text-slate-500 dark:text-slate-400 font-mono truncate">{{ row.staff_id }}</span>
</div>
</div>
<span class="flex-shrink-0 text-sm font-semibold text-slate-600 dark:text-slate-300">{{ row.count }} action{{ 's' if row.count > 1 else '' }}</span>
</div>
{% else %}
<div class="px-5 py-6 text-center text-sm text-slate-500 dark:text-slate-400">Aucune action enregistrée</div>
{% endfor %}
</div>
</div>
</div>
Le bot enregistre automatiquement les avertissements, exclusions et bannissements. <div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden mb-6">
<details class="group">
<table> <summary class="flex items-center justify-between px-5 py-4 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors">
<thead> <span class="font-medium text-slate-800 dark:text-white">Commandes de modération disponibles</span>
<tr> <svg class="w-5 h-5 text-slate-400 group-open:rotate-180 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
<th>Commande</th> </summary>
<th>Description</th> <div class="border-t border-slate-200 dark:border-slate-700">
</tr> <div class="divide-y divide-slate-200 dark:divide-slate-700 text-sm">
</thead> <div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
<tbody> <code class="text-slate-700 dark:text-slate-300 font-mono">!averto @user raison</code>
<tr> <span class="text-slate-500 dark:text-slate-400">Avertit un utilisateur</span>
<td><strong>!averto @utilisateur raison</strong><br><small>Alias : !warn, !av, !avertissement</small></td> </div>
<td>Avertit un utilisateur et enregistre l'avertissement dans la base de données</td> <div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
</tr> <code class="text-slate-700 dark:text-slate-300 font-mono">!delaverto id</code>
<tr> <span class="text-slate-500 dark:text-slate-400">Retire un avertissement</span>
<td><strong>!delaverto id</strong><br><small>Alias : !removewarn, !delwarn</small></td> </div>
<td>Retire un avertissement en utilisant son numéro d'ID</td> <div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
</tr> <code class="text-slate-700 dark:text-slate-300 font-mono">!warnings [@user]</code>
<tr> <span class="text-slate-500 dark:text-slate-400">Liste les événements de modération</span>
<td><strong>!warnings</strong> ou <strong>!warnings @utilisateur</strong><br><small>Alias : !listevent, !listwarn</small></td> </div>
<td>Affiche la liste des événements de modération (tous ou pour un utilisateur spécifique)</td> <div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
</tr> <code class="text-slate-700 dark:text-slate-300 font-mono">!inspect @user</code>
<tr> <span class="text-slate-500 dark:text-slate-400">Informations sur un utilisateur</span>
<td><strong>!inspect @utilisateur</strong> ou <strong>!inspect id</strong></td> </div>
<td>Affiche des informations détaillées sur un utilisateur : création du compte, date d'arrivée, historique de modération</td> <div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
</tr> <code class="text-slate-700 dark:text-slate-300 font-mono">!kick @user raison</code>
<tr> <span class="text-slate-500 dark:text-slate-400">Expulse un utilisateur</span>
<td><strong>!kick @utilisateur raison</strong></td> </div>
<td>Expulse un utilisateur du serveur</td> <div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
</tr> <code class="text-slate-700 dark:text-slate-300 font-mono">!ban @user raison</code>
<tr> <span class="text-slate-500 dark:text-slate-400">Bannit un utilisateur</span>
<td><strong>!ban @utilisateur raison</strong></td> </div>
<td>Bannit définitivement un utilisateur du serveur</td> <div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
</tr> <code class="text-slate-700 dark:text-slate-300 font-mono">!unban id</code>
<tr> <span class="text-slate-500 dark:text-slate-400">Révoque un bannissement</span>
<td><strong>!unban discord_id</strong> ou <strong>!unban #sanction_id raison</strong></td> </div>
<td>Révoque le bannissement d'un utilisateur et lui envoie une invitation</td> <div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
</tr> <code class="text-slate-700 dark:text-slate-300 font-mono">!banlist</code>
<tr> <span class="text-slate-500 dark:text-slate-400">Liste des utilisateurs bannis</span>
<td><strong>!banlist</strong></td> </div>
<td>Affiche la liste des utilisateurs actuellement bannis du serveur</td> <div class="px-5 py-3 flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
</tr> <code class="text-slate-700 dark:text-slate-300 font-mono">!transfert #canal message_id</code>
<tr> <span class="text-slate-500 dark:text-slate-400">Transfère un message vers un autre canal (textuel, thread ou forum)</span>
<td><strong>!aide</strong><br><small>Alias : !help</small></td> </div>
<td>Affiche l'aide avec toutes les commandes disponibles</td> </div>
</tr> </div>
</tbody> </details>
</table> </div>
</p>
{% if not event %} {% if not event %}
<h2>Événements de modération</h2> <div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden mb-6">
<table class="moderation"> <div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<h2 class="text-lg font-medium text-slate-800 dark:text-white">Événements de modération</h2>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead> <thead>
<tr> <tr class="bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700">
<th>Type</th> <th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Type</th>
<th>Utilisateur</th> <th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Utilisateur</th>
<th>Discord ID</th> <th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Date</th>
<th>Date & Heure</th> <th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Raison</th>
<th>Raison</th> <th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Staff</th>
<th>Staff</th> <th class="px-4 py-3 text-right text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Actions</th>
<th>#</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody class="divide-y divide-slate-200 dark:divide-slate-700">
{% for mod_event in events %} {% for mod_event in events %}
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors">
<td class="px-4 py-3">
{% if mod_event.type == 'ban' %}
<span class="text-xs font-medium text-red-600 dark:text-red-400">Ban</span>
{% elif mod_event.type == 'kick' %}
<span class="text-xs font-medium text-orange-600 dark:text-orange-400">Kick</span>
{% elif mod_event.type == 'warn' or mod_event.type == 'warning' %}
<span class="text-xs font-medium text-yellow-600 dark:text-yellow-400">Warn</span>
{% elif mod_event.type == 'unban' %}
<span class="text-xs font-medium text-green-600 dark:text-green-400">Unban</span>
{% elif mod_event.type == 'transfer' %}
<span class="text-xs font-medium text-blue-600 dark:text-blue-400">Transfert</span>
{% elif mod_event.type == 'timeout' %}
<span class="text-xs font-medium text-purple-600 dark:text-purple-400">Timeout</span>
{% else %}
<span class="text-xs font-medium text-slate-600 dark:text-slate-400">{{ mod_event.type }}</span>
{% endif %}
</td>
<td class="px-4 py-3">
<div class="flex flex-col">
<span class="text-sm font-medium text-slate-800 dark:text-white">{{ mod_event.username }}</span>
<span class="text-xs text-slate-500 dark:text-slate-400 font-mono">{{ mod_event.discord_id }}</span>
</div>
</td>
<td class="px-4 py-3 text-sm text-slate-600 dark:text-slate-400 whitespace-nowrap">
{{ mod_event.created_at.strftime('%d/%m/%Y %H:%M') if mod_event.created_at else 'N/A' }}
</td>
<td class="px-4 py-3 text-sm text-slate-600 dark:text-slate-400 max-w-xs">
<div class="line-clamp-2">{{ mod_event.reason }}</div>
</td>
<td class="px-4 py-3 text-sm text-slate-600 dark:text-slate-400">
{{ mod_event.staff_name }}
</td>
<td class="px-4 py-3">
<div class="flex items-center justify-end gap-2">
<a href="{{ url_for('open_edit_moderation_event', event_id = mod_event.id) }}" class="text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 transition-colors">
Modifier
</a>
<a href="{{ url_for('delete_moderation_event', event_id = mod_event.id) }}" onclick="return confirm('Êtes-vous sûr de vouloir supprimer cet événement ?')" class="text-sm text-slate-500 hover:text-red-600 dark:hover:text-red-400 transition-colors">
Supprimer
</a>
</div>
</td>
</tr>
{% else %}
<tr> <tr>
<td>{{ mod_event.type }}</td> <td colspan="6" class="px-4 py-8 text-center text-sm text-slate-500 dark:text-slate-400">
<td>{{ mod_event.username }}</td> Aucun événement de modération
<td>{{ mod_event.discord_id }}</td>
<td>{{ mod_event.created_at.strftime('%d/%m/%Y %H:%M') if mod_event.created_at else 'N/A' }}</td>
<td>{{ mod_event.reason }}</td>
<td>{{ mod_event.staff_name }}</td>
<td>
<a href="{{ url_for('open_edit_moderation_event', event_id = mod_event.id) }}" class="icon"></a>
<a href="{{ url_for('delete_moderation_event', event_id = mod_event.id) }}" onclick="return confirm('Êtes-vous sûr de vouloir supprimer cet événement ?')" class="icon">🗑</a>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
</div>
{% endif %} {% endif %}
{% if event %} {% if event %}
<h2>Editer un événement</h2> <div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-5">
<form action="{{ url_for('update_moderation_event', event_id = event.id) }}" method="POST"> <h2 class="text-lg font-medium text-slate-800 dark:text-white mb-5">Modifier l'événement</h2>
<label for="type">Type</label>
<input name="type" type="text" value="{{ event.type }}" disabled />
<label for="username">Utilisateur</label>
<input name="username" type="text" value="{{ event.username }}" disabled />
<label for="discord_id">Discord ID</label>
<input name="discord_id" type="text" value="{{ event.discord_id }}" disabled />
<label for="reason">Raison</label>
<input name="reason" type="text" value="{{ event.reason }}" required="required" />
<label for="staff_name">Staff</label>
<input name="staff_name" type="text" value="{{ event.staff_name }}" disabled />
<input type="Submit" value="Modifier">
<a href="{{ url_for('moderation') }}">Annuler</a>
</form>
{% endif %}
<form action="{{ url_for('update_moderation_event', event_id = event.id) }}" method="POST" class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Type</label>
<input type="text" value="{{ event.type }}" disabled class="w-full px-3 py-2 bg-slate-100 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-500 dark:text-slate-400 cursor-not-allowed">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Staff</label>
<input type="text" value="{{ event.staff_name }}" disabled class="w-full px-3 py-2 bg-slate-100 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-500 dark:text-slate-400 cursor-not-allowed">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Utilisateur</label>
<input type="text" value="{{ event.username }}" disabled class="w-full px-3 py-2 bg-slate-100 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-500 dark:text-slate-400 cursor-not-allowed">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Discord ID</label>
<input type="text" value="{{ event.discord_id }}" disabled class="w-full px-3 py-2 bg-slate-100 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-500 dark:text-slate-400 cursor-not-allowed font-mono">
</div>
</div>
<div>
<label for="reason" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Raison</label>
<input type="text" name="reason" id="reason" value="{{ event.reason }}" required class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-white focus:ring-2 focus:ring-slate-500 focus:border-transparent transition-all">
</div>
<div class="flex items-center justify-end gap-3">
<a href="{{ url_for('moderation') }}" class="px-4 py-2 text-slate-700 dark:text-slate-300 text-sm font-medium rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">
Annuler
</a>
<button type="submit" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors">
Enregistrer
</button>
</div>
</form>
</div>
{% endif %}
{% endblock %} {% endblock %}
+206
View File
@@ -0,0 +1,206 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Patreon — Notifications de posts</h1>
{% if request.args.get('msg') %}
{% set msg_type = request.args.get('type') %}
<div class="mb-4 p-4 rounded-lg {% if msg_type == 'success' %}bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-300{% elif msg_type == 'error' %}bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300{% else %}bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300{% endif %}">
{{ request.args.get('msg') }}
</div>
{% endif %}
<div class="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
<p class="text-gray-700 dark:text-gray-300">
Notifications des nouveaux posts Patreon via le flux RSS public. Renseignez le nom du créateur Patreon
et choisissez le canal Discord de destination. Le bot vérifie le flux environ toutes les 10 minutes.
</p>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center gap-3 mb-6">
<svg class="w-8 h-8 text-orange-500" fill="currentColor" viewBox="0 0 24 24"><path d="M14.82 2.41c3.96 0 7.18 3.24 7.18 7.21 0 3.96-3.22 7.18-7.18 7.18-2.56 0-4.81-1.34-6.09-3.36L2 20.6h3.12l2.6-2.63 1.38 2.63h3.36l-2.75-5.23c.27-.39.51-.8.71-1.24l4.82 6.47h3.76l-6.47-8.67c.19-.76.29-1.55.29-2.37 0-1.32-.28-2.58-.78-3.71h3.63L14.82 2.41zM14.82 4.92c2.57 0 4.67 2.09 4.67 4.7 0 2.57-2.1 4.67-4.67 4.67-2.58 0-4.68-2.1-4.68-4.67 0-2.61 2.1-4.7 4.68-4.7z"/></svg>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Configuration Patreon</h2>
</div>
<form action="{{ url_for('updatePatreon') }}" method="POST" class="space-y-6">
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="patreon_enable" {% if configuration.getValue('patreon_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-orange-600 focus:ring-orange-500 dark:bg-gray-700">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Activer les notifications Patreon</span>
</label>
</div>
<div>
<label for="patreon_creator" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Nom du créateur Patreon</label>
<input type="text" name="patreon_creator" id="patreon_creator"
value="{{ configuration.getValue('patreon_creator') or '' }}"
placeholder="ex: nom_du_createur"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Le nom tel qu'il apparaît dans l'URL : patreon.com/<strong>nom_du_createur</strong></p>
</div>
<div>
<label for="patreon_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal Discord pour les notifications</label>
<select name="patreon_channel_id" id="patreon_channel_id"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all">
<option value="">— Choisir un canal —</option>
{% for channel in channels %}
<option value="{{ channel.id }}" {% if configuration.getIntValue('patreon_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }}</option>
{% endfor %}
</select>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Mentions (optionnel)</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Choisissez qui mentionner au début du message (avant l'embed).</p>
<div class="flex flex-wrap gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="patreon_mention_everyone" {% if mention_everyone %}checked{% endif %}
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-orange-600 focus:ring-orange-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">@everyone</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="patreon_mention_here" {% if mention_here %}checked{% endif %}
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-orange-600 focus:ring-orange-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">@here</span>
</label>
</div>
{% if roles %}
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rôles à mentionner</p>
{% if roles|length > 1 %}
<div class="flex flex-wrap gap-1 border-b border-gray-200 dark:border-gray-600 mb-3">
{% for guild_data in roles %}
<button type="button" class="patreon-role-tab px-4 py-2 text-sm font-medium rounded-t-lg transition-colors {% if loop.first %}bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-white{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600{% endif %}"
data-tab="patreon-roles-{{ guild_data.guild_id }}" {% if loop.first %}data-default{% endif %}>
{{ guild_data.guild_name }}
</button>
{% endfor %}
</div>
{% endif %}
{% for guild_data in roles %}
<div id="patreon-roles-{{ guild_data.guild_id }}" class="patreon-role-panel {% if not loop.first %}hidden{% endif %} max-h-48 overflow-y-auto border border-gray-200 dark:border-gray-600 rounded-lg p-3 space-y-2">
{% for role in guild_data.roles %}
<label class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600/50 p-1 rounded">
<input type="checkbox" name="patreon_mention_roles" value="{{ role.id }}"
{% if role.id|string in mention_role_ids %}checked{% endif %}
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-orange-600 focus:ring-orange-500 dark:bg-gray-700">
{% if role.color is defined and role.color is not none and role.color.value != 0 %}
<span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color:#{{ '%06x'|format(role.color.value) }}"></span>
{% else %}
<span class="w-3 h-3 rounded-full flex-shrink-0 bg-gray-400"></span>
{% endif %}
<span class="text-sm text-gray-700 dark:text-gray-300">{{ role.name }}</span>
</label>
{% endfor %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
<button type="submit"
class="px-6 py-2.5 bg-orange-600 hover:bg-orange-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Enregistrer
</button>
</form>
<div class="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
<h3 class="font-medium text-gray-800 dark:text-gray-200 mb-3">Aperçu de l'embed Discord</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Exemple du message envoyé dans le canal lors d'un nouveau post Patreon.</p>
<div class="inline-block rounded-r-lg overflow-hidden border border-gray-300 dark:border-gray-600 bg-[#2f3136] max-w-lg shadow-lg" style="border-left: 4px solid #F96854;">
<div class="p-4">
<div class="flex items-center gap-2 mb-2">
<img src="https://c5.patreon.com/external/favicon/favicon-32x32.png" alt="" class="w-6 h-6 rounded-full">
<span class="text-[#dcddde] text-sm font-medium">Nom du créateur</span>
</div>
<a href="#" class="text-[#00a8fc] hover:underline font-semibold text-base block mb-2">Titre du post Patreon</a>
<p class="text-[#dcddde] text-sm leading-relaxed mb-3">Ceci est un aperçu de la description du post Patreon. Le contenu HTML est automatiquement nettoyé et tronqué pour l'embed Discord...</p>
<div class="rounded overflow-hidden bg-[#202225] aspect-video flex items-center justify-center my-2">
<svg class="w-12 h-12 text-gray-500" fill="currentColor" viewBox="0 0 24 24"><path d="M14.82 2.41c3.96 0 7.18 3.24 7.18 7.21 0 3.96-3.22 7.18-7.18 7.18-2.56 0-4.81-1.34-6.09-3.36L2 20.6h3.12l2.6-2.63 1.38 2.63h3.36l-2.75-5.23c.27-.39.51-.8.71-1.24l4.82 6.47h3.76l-6.47-8.67c.19-.76.29-1.55.29-2.37 0-1.32-.28-2.58-.78-3.71h3.63L14.82 2.41zM14.82 4.92c2.57 0 4.67 2.09 4.67 4.7 0 2.57-2.1 4.67-4.67 4.67-2.58 0-4.68-2.1-4.68-4.67 0-2.61 2.1-4.7 4.68-4.7z"/></svg>
</div>
<p class="text-xs text-[#72767d] pt-1">MamieHenriette &bull; Patreon</p>
</div>
</div>
</div>
</div>
{% if posts %}
<div class="mt-8">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Historique des posts Patreon</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ posts|length }} post{{ 's' if posts|length > 1 else '' }} enregistré{{ 's' if posts|length > 1 else '' }}</p>
</div>
<div class="p-6">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{% for post in posts %}
<article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden hover:shadow-md transition-shadow flex flex-col">
<div class="p-4 flex flex-col flex-1">
<div class="flex items-start justify-between gap-2 mb-2">
<h3 class="font-semibold text-gray-900 dark:text-white line-clamp-2 flex-1" title="{{ post.title or 'Sans titre' }}">
{% if post.link %}
<a href="{{ post.link }}" target="_blank" rel="noopener noreferrer" class="hover:text-orange-600 dark:hover:text-orange-400 transition-colors">{{ post.title or 'Sans titre' }}</a>
{% else %}
{{ post.title or 'Sans titre' }}
{% endif %}
</h3>
{% if post.notified %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 flex-shrink-0">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
Notifié
</span>
{% else %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 flex-shrink-0">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01"></path></svg>
Non notifié
</span>
{% endif %}
</div>
{% if post.description %}
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-3">{{ post.description|striptags|truncate(150) }}</p>
{% endif %}
{% if post.published_formatted %}
<p class="text-xs text-gray-500 dark:text-gray-500 mb-3">{{ post.published_formatted }}</p>
{% endif %}
<div class="mt-auto">
<form action="{{ url_for('sendPatreonToDiscord') }}" method="POST" class="w-full">
<input type="hidden" name="guid" value="{{ post.guid }}">
<button type="submit" class="w-full inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white transition-colors focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/></svg>
{% if post.notified %}Re-notifier{% else %}Envoyer sur Discord{% endif %}
</button>
</form>
</div>
</div>
</article>
{% endfor %}
</div>
</div>
</div>
</div>
{% else %}
<div class="mt-8 p-4 rounded-lg bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700">
<p class="text-gray-600 dark:text-gray-400 text-sm">Aucun post Patreon enregistré. Les posts apparaîtront ici après la première vérification du flux RSS.</p>
</div>
{% endif %}
<script>
document.querySelectorAll('.patreon-role-tab').forEach(btn => {
btn.addEventListener('click', function() {
var tabId = this.getAttribute('data-tab');
document.querySelectorAll('.patreon-role-panel').forEach(p => p.classList.add('hidden'));
document.querySelectorAll('.patreon-role-tab').forEach(b => {
b.classList.remove('bg-gray-200', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white');
b.classList.add('bg-gray-100', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
});
document.getElementById(tabId).classList.remove('hidden');
this.classList.remove('bg-gray-100', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
this.classList.add('bg-gray-200', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white');
});
});
document.querySelector('.patreon-role-tab[data-default]')?.click();
</script>
{% endblock %}
+153 -42
View File
@@ -1,61 +1,172 @@
{% extends "template.html" %} {% extends "template.html" %}
{% block content %} {% block content %}
<h1>Proton DB</h1> <div class="mb-8">
<p>ProtonDB évalue la compatibilité des jeux Windows sur Linux via Steam Play.</p> <h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">ProtonDB</h1>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p class="text-gray-700 dark:text-gray-300">
ProtonDB évalue la compatibilité des jeux Windows sur Linux via Steam Play.
</p>
</div>
</div>
{% if configuration.getValue('proton_db_enable_enable') %} {% if configuration.getValue('proton_db_enable_enable') or configuration.getValue('proton_db_twitch_enable') %}
<h2>Game alias</h2> <div class="mb-8">
<table> <h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Alias de jeux</h2>
<thead> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr> <tr>
<th>Alias</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Alias</th>
<th>Game</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Jeu</th>
<th>#</th> <th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-24">Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for a in aliases %} {% for a in aliases %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<code class="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-purple-600 dark:text-purple-400 font-mono">{{ a.alias }}</code>
</td>
<td class="px-6 py-4 text-gray-700 dark:text-gray-300">{{ a.name }}</td>
<td class="px-6 py-4 text-center">
<a href="{{ url_for('delGameAlias', id = a.id) }}"
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cet alias ?')"
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors text-red-600 dark:text-red-400 inline-block"
title="Supprimer">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
</a>
</td>
</tr>
{% else %}
<tr> <tr>
<td>{{a.alias}}</td> <td colspan="3" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
<td>{{a.name}}</td> Aucun alias configuré. Ajoutez-en un ci-dessous.
<td><a href="{{ url_for('delGameAlias', id = a.id) }}" </td>
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cet alias ?')">Supprimer</a></td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
</div>
</div>
<h2>Ajouter un Alias</h2> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-8">
<form action="{{ url_for('addGameAlias') }}" method="POST"> <h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Ajouter un alias</h2>
<label for="alias">Alias</label>
<input name="alias" type="text" maxlength="32" required="required" /> <form action="{{ url_for('addGameAlias') }}" method="POST" class="space-y-6">
<label for="name">Nom</label> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<input name="name" type="text" maxlength="256" required="required" /> <div>
<input type="Submit" value="Ajouter"> <label for="alias" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Alias</label>
<p>Si vous créez un alias <strong>GTA : Grand Theft Auto</strong> alors si un utilisateur rentre la commande <input name="alias" id="alias" type="text" maxlength="32" required
<strong>!protondb GTA 5</strong> cela fera une recherche sur <strong>Grand Theft Auto 5</strong>. class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
placeholder="GTA"/>
</div>
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Nom du jeu</label>
<input name="name" id="name" type="text" maxlength="256" required
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
placeholder="Grand Theft Auto"/>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<p class="text-sm text-gray-600 dark:text-gray-400">
Si vous créez un alias <code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">GTA</code><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">Grand Theft Auto</code>,
alors la commande <code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">!protondb GTA 5</code> fera une recherche sur <strong class="text-gray-800 dark:text-gray-200">Grand Theft Auto 5</strong>.
</p> </p>
</form> </div>
<div>
<button type="submit"
class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Ajouter l'alias
</button>
</div>
</form>
</div>
{% endif %} {% endif %}
<h2>Configuration</h2> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<form action="{{ url_for('updateConfiguration') }}" method="POST"> <h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Configuration</h2>
<label for="proton_db_enable_enable">Activer</label>
<input type="checkbox" name="proton_db_enable_enable" {% if configuration.getValue('proton_db_enable_enable') %} <form action="{{ url_for('updateConfiguration') }}" method="POST" class="space-y-6">
checked="checked" {% endif %}> <div class="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<label>Activer la commande Proton DB</label> <input type="checkbox" name="proton_db_enable_enable" id="proton_db_enable_enable"
<label for="proton_db_api_id">API ID</label> {% if configuration.getValue('proton_db_enable_enable') %}checked{% endif %}
<input name="proton_db_api_id" type="text" value="{{ configuration.getValue('proton_db_api_id') }}" /> class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700">
<label for="proton_db_api_key">Clé API</label> <label for="proton_db_enable_enable" class="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
<input name="proton_db_api_key" type="text" value="{{ configuration.getValue('proton_db_api_key') }}" /> Activer la commande ProtonDB sur Discord (<code class="px-1 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">!pdb</code>)
<input type="Submit" value="Définir"> </label>
<p>Pour trouver les clés, dans votre navigateur avec l'outil d'inspection ouvert (F12 ou clic droit > Inspecter </div>
l'élément dans Firefox/Chrome) faites une recherche de jeux sur protondb,
puis cherchez les clés dans les requêtes (onglet Réseau/Network), <div class="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<a href="/static/img/algolia-key.jpg" target="_blank">comme le montre cet exemple</a> <input type="checkbox" name="proton_db_twitch_enable" id="proton_db_twitch_enable"
{% if configuration.getValue('proton_db_twitch_enable') %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500 dark:bg-gray-700">
<label for="proton_db_twitch_enable" class="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
Activer la commande ProtonDB sur Twitch (<code class="px-1 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">!pdb</code>)
</label>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<label for="proton_db_twitch_permission" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Permission minimale pour <code class="px-1 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">!pdb</code> sur Twitch
</label>
<select name="proton_db_twitch_permission" id="proton_db_twitch_permission"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all">
{% set current_perm = configuration.getValue('proton_db_twitch_permission') or 'viewer' %}
<option value="viewer" {% if current_perm == 'viewer' %}selected{% endif %}>👁️ Viewer (tout le monde)</option>
<option value="sub" {% if current_perm == 'sub' %}selected{% endif %}>⭐ Abonné (Sub)</option>
<option value="vip" {% if current_perm == 'vip' %}selected{% endif %}>💎 VIP</option>
<option value="moderator" {% if current_perm == 'moderator' %}selected{% endif %}>🛡️ Modérateur</option>
</select>
</div>
<div>
<label for="proton_db_twitch_cooldown" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Cooldown entre deux <code class="px-1 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">!pdb</code> (secondes)
</label>
<input type="number" name="proton_db_twitch_cooldown" id="proton_db_twitch_cooldown"
min="0" max="3600" step="1"
value="{{ configuration.getValue('proton_db_twitch_cooldown') or 0 }}"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
placeholder="0"/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">0 = pas de cooldown</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="proton_db_api_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">API ID</label>
<input name="proton_db_api_id" id="proton_db_api_id" type="text"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
value="{{ configuration.getValue('proton_db_api_id') }}"/>
</div>
<div>
<label for="proton_db_api_key" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Clé API</label>
<input name="proton_db_api_key" id="proton_db_api_key" type="text"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
value="{{ configuration.getValue('proton_db_api_key') }}"/>
</div>
</div>
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<p class="text-sm text-amber-800 dark:text-amber-200">
Pour trouver les clés : ouvrez l'outil d'inspection (F12) dans votre navigateur, faites une recherche de jeux sur ProtonDB,
puis cherchez les clés dans les requêtes (onglet Réseau/Network).
<a href="/static/img/algolia-key.jpg" target="_blank" class="underline hover:no-underline">Voir l'exemple</a>
</p> </p>
</form> </div>
<div>
<button type="submit"
class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Enregistrer la configuration
</button>
</div>
</form>
</div>
{% endblock %} {% endblock %}
+41
View File
@@ -0,0 +1,41 @@
{% extends "template.html" %}
{% block content %}
<div class="max-w-md mx-auto py-12">
<h1 class="text-2xl font-bold text-slate-800 dark:text-white mb-6 text-center">Créer un compte</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mb-4 space-y-2">
{% for category, msg in messages %}
<p class="p-3 rounded-lg text-sm {% if category == 'error' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200{% else %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200{% endif %}">{{ msg }}</p>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="post" action="{{ url_for('register') }}" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Nom d'utilisateur</label>
<input type="text" id="username" name="username" required minlength="3" autocomplete="username" class="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" placeholder="min. 3 caractères">
</div>
<div>
<label for="email" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Adresse e-mail</label>
<input type="email" id="email" name="email" required autocomplete="email" class="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" placeholder="vous@exemple.com">
</div>
<div>
<label for="password" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Mot de passe</label>
<input type="password" id="password" name="password" required minlength="8" autocomplete="new-password" class="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" placeholder="min. 8 caractères">
</div>
<div>
<label for="password_confirm" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Confirmer le mot de passe</label>
<input type="password" id="password_confirm" name="password_confirm" required minlength="8" autocomplete="new-password" class="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent">
</div>
<button type="submit" class="w-full py-2.5 px-4 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">S'inscrire</button>
</form>
<p class="mt-4 text-center text-sm text-slate-600 dark:text-slate-400">
Déjà un compte ? <a href="{{ url_for('login') }}" class="text-primary-600 dark:text-primary-400 hover:underline">Se connecter</a>
</p>
</div>
{% endblock %}
+386
View File
@@ -0,0 +1,386 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">Paramètres et Permissions</h1>
<p class="text-gray-600 dark:text-gray-400">Configuration des rôles et des accès aux pages (super administrateur uniquement)</p>
</div>
<button type="button" onclick="document.getElementById('help-modal').classList.remove('hidden')"
class="px-4 py-2 rounded-lg bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 hover:bg-primary-200 dark:hover:bg-primary-900/50 transition-colors flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Aide</span>
</button>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mb-6 space-y-2">
{% for category, msg in messages %}
<div class="p-4 rounded-lg {% if category == 'error' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800{% else %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-800{% endif %}">
{{ msg }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="space-y-8">
<!-- Inscriptions -->
<section class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 shadow-sm">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<svg class="w-6 h-6 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
</div>
<div class="flex-grow">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Inscriptions</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Autoriser ou bloquer la création de nouveaux comptes par les visiteurs</p>
<form action="{{ url_for('settings_toggle_registration') }}" method="post">
<label class="inline-flex items-center gap-3 cursor-pointer group">
<div class="relative">
<input type="checkbox" name="enabled" value="1" {% if registration_enabled %}checked{% endif %}
onchange="this.form.submit()"
class="sr-only peer">
<div class="w-14 h-7 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-green-500 dark:peer-checked:bg-green-600 transition-colors"></div>
<div class="absolute left-1 top-1 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-7 shadow-md"></div>
</div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-white">
{% if registration_enabled %}✓ Inscriptions activées{% else %}✗ Inscriptions désactivées{% endif %}
</span>
</label>
</form>
</div>
</div>
</section>
<!-- Rôles -->
<section class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
<div class="flex-grow">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Hiérarchie des rôles</h2>
<p class="text-sm text-gray-600 dark:text-gray-400">
Les rôles définissent le niveau d'accès des utilisateurs. Plus le niveau est élevé, plus les permissions sont étendues.
</p>
</div>
</div>
</div>
<div class="p-6">
<div class="space-y-3 mb-6">
{% for r in roles %}
<div class="bg-gray-50 dark:bg-gray-700/30 rounded-lg p-4 border border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 transition-colors">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center" style="background-color: {{ r.color or '#6B7280' }}20;">
<span class="text-2xl" style="color: {{ r.color or '#6B7280' }};"></span>
</div>
<div class="flex-grow min-w-0">
<div class="flex items-center gap-3 mb-2">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ r.name }}</h3>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
style="background-color: {{ r.color or '#6B7280' }}20; color: {{ r.color or '#6B7280' }};">
Niveau {{ r.level }}
</span>
</div>
{% if r.description %}
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">{{ r.description }}</p>
{% else %}
{% set default_desc = default_roles_meta.get(r.name, {}).get('description') %}
{% if default_desc %}
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">{{ default_desc }}</p>
{% endif %}
{% endif %}
<details class="group">
<summary class="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 cursor-pointer list-none flex items-center gap-1">
<svg class="w-4 h-4 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<span>Modifier ce rôle</span>
</summary>
<form action="{{ url_for('settings_role_edit', role_id=r.id) }}" method="post" class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-600 grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Niveau (0-99)</label>
<input type="number" name="level" value="{{ r.level }}" min="0" max="99"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Couleur</label>
<input type="color" name="color" value="{{ r.color or '#6B7280' }}"
class="w-full h-10 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 cursor-pointer">
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Description</label>
<input type="text" name="description" value="{{ r.description or '' }}" placeholder="Description du rôle..."
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
</div>
<div class="sm:col-span-2 flex items-center gap-2">
<button type="submit" class="px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white text-sm font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
Enregistrer
</button>
{% if r.name not in ['viewer_twitch','utilisateur_discord','moderateur_discord','expert_discord','moderateur_twitch','super_administrateur'] %}
<button type="button" onclick="if(confirm('Supprimer ce rôle ?')) { this.closest('details').querySelector('form').setAttribute('action', '{{ url_for('settings_role_delete', role_id=r.id) }}'); this.closest('form').submit(); }"
class="px-4 py-2 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm font-medium hover:bg-red-200 dark:hover:bg-red-900/50 transition-colors">
Supprimer
</button>
{% endif %}
</div>
</form>
</details>
</div>
</div>
</div>
{% endfor %}
</div>
<details class="bg-primary-50 dark:bg-primary-900/20 rounded-lg border border-primary-200 dark:border-primary-800">
<summary class="px-4 py-3 cursor-pointer list-none flex items-center gap-2 text-primary-700 dark:text-primary-300 font-medium hover:text-primary-800 dark:hover:text-primary-200">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span>Créer un nouveau rôle</span>
</summary>
<form action="{{ url_for('settings_role_add') }}" method="post" class="p-4 pt-0 grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Nom du rôle *</label>
<input type="text" name="name" placeholder="ex: editeur_contenu" required minlength="2"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Niveau (0-99) *</label>
<input type="number" name="level" value="0" min="0" max="99"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Couleur</label>
<input type="color" name="color" value="#6B7280"
class="w-full h-10 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 cursor-pointer">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Icône (optionnel)</label>
<input type="text" name="icon" placeholder="ex: star, shield, user"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Description</label>
<input type="text" name="description" placeholder="Description du rôle..."
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
</div>
<div class="sm:col-span-2">
<button type="submit" class="px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
Créer le rôle
</button>
</div>
</form>
</details>
</div>
</section>
<!-- Permissions par page -->
<section class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<svg class="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div class="flex-grow">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Accès aux pages</h2>
<p class="text-sm text-gray-600 dark:text-gray-400">
Définissez le rôle minimum requis pour accéder à chaque section de l'interface
</p>
</div>
</div>
</div>
<!-- Appliquer en masse -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/30">
<details class="group">
<summary class="cursor-pointer list-none flex items-center gap-2 text-gray-700 dark:text-gray-300 font-medium hover:text-gray-900 dark:hover:text-white">
<svg class="w-5 h-5 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<span>⚡ Modification en masse</span>
</summary>
<form action="{{ url_for('settings_permissions_bulk') }}" method="post" class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-600">
<div class="flex flex-wrap items-center gap-4 mb-4">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<span>Appliquer le rôle :</span>
<select name="role" required class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
{% for r in roles %}
<option value="{{ r.name }}" style="color: {{ r.color or '#6B7280' }};">{{ r.name }} (niveau {{ r.level }})</option>
{% endfor %}
</select>
</label>
<span class="text-sm text-gray-500 dark:text-gray-400">aux pages cochées :</span>
</div>
<div class="flex flex-wrap gap-2 mb-4">
<button type="button" onclick="document.querySelectorAll('.bulk-page-cb').forEach(c => c.checked = true)"
class="text-xs px-3 py-1.5 rounded-lg bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors">
Tout cocher
</button>
<button type="button" onclick="document.querySelectorAll('.bulk-page-cb').forEach(c => c.checked = false)"
class="text-xs px-3 py-1.5 rounded-lg bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors">
Tout décocher
</button>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2 mb-4">
{% for page_key, meta in page_metadata.items() %}
<label class="flex items-center gap-2 px-3 py-2 rounded-lg bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 hover:border-primary-300 dark:hover:border-primary-600 cursor-pointer transition-colors">
<input type="checkbox" name="page_keys" value="{{ page_key }}" class="bulk-page-cb rounded border-gray-300 dark:border-gray-600 text-primary-600 focus:ring-primary-500">
<span class="text-xs text-gray-700 dark:text-gray-300">{{ meta.label }}</span>
</label>
{% endfor %}
</div>
<button type="submit" class="px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
Appliquer à la sélection
</button>
</form>
</details>
</div>
<!-- Pages par catégorie -->
<div class="p-6">
{% for category_key in ['general', 'content', 'moderation', 'config', 'admin'] %}
{% if category_key in pages_by_category %}
{% set category_info = category_labels[category_key] %}
<div class="mb-8 last:mb-0">
<div class="flex items-center gap-3 mb-4">
<div class="w-8 h-8 rounded-lg flex items-center justify-center" style="background-color: {{ category_info.color }}20;">
<span class="text-lg" style="color: {{ category_info.color }};"></span>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ category_info.label }}</h3>
<div class="flex-grow h-px bg-gray-200 dark:bg-gray-700"></div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% for page_data in pages_by_category[category_key] %}
{% set page_key = page_data.key %}
{% set meta = page_data.meta %}
{% set perm = page_data.permission %}
{% set min_lvl = perm.min_level if perm else 0 %}
<div class="bg-gray-50 dark:bg-gray-700/30 rounded-lg p-4 border border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 transition-colors">
<div class="flex items-start justify-between gap-3 mb-2">
<div>
<h4 class="font-medium text-gray-900 dark:text-white mb-1">{{ meta.label }}</h4>
<p class="text-xs text-gray-600 dark:text-gray-400">{{ meta.description }}</p>
</div>
{% for r in roles %}
{% if r.level == min_lvl %}
<span class="flex-shrink-0 inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium whitespace-nowrap"
style="background-color: {{ r.color or '#6B7280' }}20; color: {{ r.color or '#6B7280' }};">
{{ r.name }}
</span>
{% endif %}
{% endfor %}
</div>
<form action="{{ url_for('settings_permissions_update') }}" method="post" class="flex items-center gap-2">
<input type="hidden" name="page_key" value="{{ page_key }}">
<select name="role" class="flex-grow px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-xs focus:ring-2 focus:ring-primary-500">
{% for r in roles %}
<option value="{{ r.name }}" {% if r.level == min_lvl %}selected{% endif %}>{{ r.name }} (niv. {{ r.level }})</option>
{% endfor %}
</select>
<button type="submit" class="px-3 py-1.5 rounded-lg bg-primary-600 dark:bg-primary-500 text-white text-xs font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
OK
</button>
</form>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% endfor %}
</div>
</section>
</div>
<!-- Modal d'aide -->
<div id="help-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" onclick="document.getElementById('help-modal').classList.add('hidden')"></div>
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full">
<div class="bg-white dark:bg-gray-800 px-6 pt-6 pb-4">
<div class="flex items-start justify-between mb-4">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">Guide des permissions</h3>
<button type="button" onclick="document.getElementById('help-modal').classList.add('hidden')"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="space-y-4 text-sm text-gray-600 dark:text-gray-400">
<div>
<h4 class="font-semibold text-gray-900 dark:text-white mb-2">🎭 Rôles</h4>
<p>Les rôles définissent le niveau d'autorité d'un utilisateur. Plus le niveau est élevé, plus l'utilisateur a accès à des fonctionnalités.</p>
<ul class="list-disc list-inside mt-2 space-y-1 ml-4">
<li><strong>Niveau 0-1</strong> : Accès basique en lecture seule</li>
<li><strong>Niveau 2-3</strong> : Modification de contenu et gestion basique</li>
<li><strong>Niveau 4</strong> : Modération et configuration avancée</li>
<li><strong>Niveau 5+</strong> : Administration complète du système</li>
</ul>
</div>
<div>
<h4 class="font-semibold text-gray-900 dark:text-white mb-2">🔐 Permissions par page</h4>
<p>Chaque page peut être restreinte à un rôle minimum. Un utilisateur doit avoir au moins le niveau requis pour y accéder.</p>
</div>
<div>
<h4 class="font-semibold text-gray-900 dark:text-white mb-2">📋 Catégories</h4>
<ul class="list-disc list-inside space-y-1 ml-4">
<li><strong style="color: #6B7280;"></strong> <strong>Général</strong> : Pages d'accueil et navigation</li>
<li><strong style="color: #3B82F6;"></strong> <strong>Contenu</strong> : Gestion des commandes, alertes, humeurs, etc.</li>
<li><strong style="color: #EF4444;"></strong> <strong>Modération</strong> : Outils de modération Discord/Twitch</li>
<li><strong style="color: #8B5CF6;"></strong> <strong>Configuration</strong> : Paramètres techniques des bots</li>
<li><strong style="color: #F59E0B;"></strong> <strong>Administration</strong> : Gestion des utilisateurs et permissions</li>
</ul>
</div>
<div>
<h4 class="font-semibold text-gray-900 dark:text-white mb-2">💡 Bonnes pratiques</h4>
<ul class="list-disc list-inside space-y-1 ml-4">
<li>Attribuez le rôle le plus bas possible selon les besoins</li>
<li>Testez les permissions avec un compte utilisateur avant de les déployer</li>
<li>Documentez les rôles personnalisés avec des descriptions claires</li>
<li>Vérifiez régulièrement les accès des utilisateurs</li>
</ul>
</div>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4">
<button type="button" onclick="document.getElementById('help-modal').classList.add('hidden')"
class="w-full px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
Compris !
</button>
</div>
</div>
</div>
</div>
<style>
/* Améliorer le style du toggle switch */
input[type="checkbox"].sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
</style>
{% endblock %}
+296
View File
@@ -0,0 +1,296 @@
<!DOCTYPE html>
<html lang="fr" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Shoutbox Modos</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background: #030712; }
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #374151; border-radius: 2px; }
</style>
</head>
<body class="bg-gray-950 text-gray-100 flex flex-col h-screen">
<div class="flex items-center justify-between px-3 py-1.5 bg-gray-900 border-b border-gray-800 flex-shrink-0">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a2 2 0 01-2-2v-6a2 2 0 012-2h8z"></path></svg>
<span class="text-sm font-semibold text-gray-200">Shoutbox Modos</span>
<span class="text-xs text-gray-500" id="shoutboxCount"></span>
</div>
<div class="flex items-center gap-3 flex-wrap justify-end">
<div class="flex items-center gap-1.5">
<button type="button" onclick="shoutboxFontSize(-1)" class="text-gray-400 hover:text-white text-xs leading-none px-1" title="Réduire la police">A-</button>
<span class="text-gray-500 text-xs tabular-nums min-w-[1.25rem] text-center" id="shoutboxFontLabel">14</span>
<button type="button" onclick="shoutboxFontSize(1)" class="text-gray-400 hover:text-white text-xs leading-none px-1" title="Agrandir la police">A+</button>
</div>
<button type="button" onclick="toggleShoutboxSound()" id="shoutboxSoundBtn" class="text-xs flex items-center gap-1 text-green-400 hover:text-green-300" title="Son activé">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M17.95 6.05a8 8 0 010 11.9M11 5L6 9H2v6h4l5 4V5z"></path></svg>
<span id="shoutboxSoundLabel">Son</span>
</button>
<div id="onlineIndicator" class="flex items-center gap-1.5 text-xs text-gray-400">
<span class="w-1.5 h-1.5 rounded-full bg-green-500"></span>
<span id="onlineCountText">0 en ligne</span>
</div>
</div>
</div>
<div class="flex flex-1 overflow-hidden">
<div class="flex-1 relative">
<div class="absolute inset-0 overflow-y-auto font-mono p-2" id="shoutboxDisplay" style="scrollbar-width: thin;">
<div class="text-gray-500 text-center py-4" id="shoutboxPlaceholder">Aucun message</div>
</div>
<button id="newMsgBtn" onclick="scrollToBottom()" class="hidden absolute bottom-2 left-1/2 -translate-x-1/2 z-10 px-3 py-1 rounded-full bg-indigo-600/90 hover:bg-indigo-500 text-white text-xs font-medium shadow-lg animate-pulse flex items-center gap-1.5 backdrop-blur-sm">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3"></path></svg>
<span id="newMsgText">Nouveaux messages</span>
</button>
</div>
<div class="w-24 border-l border-gray-800 bg-gray-900/50 p-2 overflow-y-auto flex-shrink-0" style="scrollbar-width: thin;">
<div class="text-xs text-gray-500 font-semibold mb-1">En ligne</div>
<div id="shoutboxOnlineList" class="space-y-1">
<div class="text-xs text-gray-600 italic">---</div>
</div>
</div>
</div>
<div class="border-t border-gray-800 p-2 bg-gray-900 flex-shrink-0">
<form onsubmit="sendShoutboxMessage(event)" class="flex gap-1.5">
<span class="text-xs text-indigo-400 font-mono flex items-center">&gt;</span>
<input type="text" id="shoutboxInput" placeholder="Message..." maxlength="500" autocomplete="off"
class="flex-1 px-2 py-1 rounded border border-gray-700 bg-gray-800 text-gray-100 text-xs font-mono focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 focus:outline-none">
<button type="submit" class="px-3 py-1 bg-indigo-600 hover:bg-indigo-700 text-white rounded text-xs font-medium transition-colors">Envoyer</button>
</form>
</div>
<script>
var knownIds = new Set();
var lastTimestamp = '';
var autoScroll = true;
var tabVisible = true;
var shoutboxAudioCtx = null;
var unreadCount = 0;
var shoutboxSoundEnabled = localStorage.getItem('shoutbox_sound') !== 'off';
var shoutboxFontSizePx = parseInt(localStorage.getItem('shoutbox_fontsize'), 10);
if (isNaN(shoutboxFontSizePx)) shoutboxFontSizePx = 14;
document.addEventListener('visibilitychange', function() { tabVisible = !document.hidden; });
function shoutboxApplyFontSize() {
var display = document.getElementById('shoutboxDisplay');
if (display) display.style.fontSize = shoutboxFontSizePx + 'px';
var label = document.getElementById('shoutboxFontLabel');
if (label) label.textContent = shoutboxFontSizePx;
localStorage.setItem('shoutbox_fontsize', String(shoutboxFontSizePx));
}
function shoutboxFontSize(delta) {
shoutboxFontSizePx = Math.max(8, Math.min(20, shoutboxFontSizePx + delta));
shoutboxApplyFontSize();
}
function shoutboxUpdateSoundUI() {
var btn = document.getElementById('shoutboxSoundBtn');
if (!btn) return;
btn.className = 'text-xs flex items-center gap-1 ' + (shoutboxSoundEnabled ? 'text-green-400 hover:text-green-300' : 'text-red-400 hover:text-red-300');
btn.title = shoutboxSoundEnabled ? 'Son activé (nouveaux messages si fenêtre en arrière-plan)' : 'Son désactivé';
var lab = document.getElementById('shoutboxSoundLabel');
if (lab) lab.textContent = shoutboxSoundEnabled ? 'Son' : 'Muet';
}
function toggleShoutboxSound() {
shoutboxSoundEnabled = !shoutboxSoundEnabled;
localStorage.setItem('shoutbox_sound', shoutboxSoundEnabled ? 'on' : 'off');
shoutboxUpdateSoundUI();
}
function shoutboxBeep() {
if (!shoutboxSoundEnabled) return;
try {
if (!shoutboxAudioCtx) shoutboxAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
var osc = shoutboxAudioCtx.createOscillator();
var gain = shoutboxAudioCtx.createGain();
osc.connect(gain);
gain.connect(shoutboxAudioCtx.destination);
osc.frequency.value = 660;
osc.type = 'sine';
gain.gain.setValueAtTime(0.15, shoutboxAudioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, shoutboxAudioCtx.currentTime + 0.3);
osc.start(shoutboxAudioCtx.currentTime);
osc.stop(shoutboxAudioCtx.currentTime + 0.3);
} catch(e) {}
}
var COLORS = ['#6366f1','#8b5cf6','#ec4899','#14b8a6','#f59e0b','#3b82f6','#10b981','#ef4444','#06b6d4','#84cc16'];
var colorMap = {};
function userColor(name) {
if (!colorMap[name]) {
var h = 0;
for (var i = 0; i < name.length; i++) h = name.charCodeAt(i) + ((h << 5) - h);
colorMap[name] = COLORS[Math.abs(h) % COLORS.length];
}
return colorMap[name];
}
var currentUser = '{{ current_user.username }}';
function esc(t) { var d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
function fmtTime(iso) {
var d = new Date(iso);
return d.getHours().toString().padStart(2,'0') + ':' + d.getMinutes().toString().padStart(2,'0');
}
function mentionUser(username) {
var input = document.getElementById('shoutboxInput');
var val = input.value;
var mention = '@' + username + ' ';
if (val && !val.endsWith(' ')) mention = ' ' + mention;
input.value = val + mention;
input.focus();
}
function renderText(text) {
var escaped = esc(text);
var mentioned = currentUser && text.toLowerCase().indexOf('@' + currentUser.toLowerCase()) >= 0;
escaped = escaped.replace(/@(\w+)/g, '<span class="text-indigo-400 font-semibold">@$1</span>');
return { html: escaped, mentioned: mentioned };
}
function addItem(item) {
if (knownIds.has(item.id)) return;
knownIds.add(item.id);
var display = document.getElementById('shoutboxDisplay');
var ph = document.getElementById('shoutboxPlaceholder');
if (ph) ph.remove();
var line = document.createElement('div');
line.className = 'py-0.5 leading-relaxed rounded px-1';
var time = '<span class="text-gray-500">[' + fmtTime(item.created_at) + ']</span> ';
if (item.type === 'message') {
var c = userColor(item.author);
var rendered = renderText(item.text);
line.innerHTML = time + '<span style="color:' + c + '" class="font-semibold cursor-pointer hover:underline" onclick="mentionUser(\'' + esc(item.author).replace(/'/g, "\\'") + '\')">&lt;' + esc(item.author) + '&gt;</span> <span class="text-gray-200">' + rendered.html + '</span>';
if (rendered.mentioned) {
line.className += ' bg-red-900/40 font-bold';
}
} else {
var au = (item.action || '').toUpperCase();
var sc = 'text-red-400';
if (['timeout','clean','permit'].indexOf(item.action) >= 0) sc = 'text-orange-400';
if (['subon','suboff','emoteon','emoteoff','follon','folloff'].indexOf(item.action) >= 0) sc = 'text-blue-400';
if (item.action === 'unban') sc = 'text-green-400';
var txt = '*** ' + au;
if (item.moderator) txt += ' par ' + item.moderator;
if (item.target) txt += ' \u2192 ' + item.target;
if (item.details && item.details !== '-') txt += ' (' + item.details + ')';
txt += ' ***';
line.innerHTML = time + '<span class="' + sc + ' font-semibold">' + esc(txt) + '</span>';
}
display.appendChild(line);
if (!tabVisible) shoutboxBeep();
while (display.children.length > 200) display.removeChild(display.firstChild);
if (autoScroll) {
display.scrollTop = display.scrollHeight;
} else {
unreadCount++;
var btn = document.getElementById('newMsgBtn');
var txt = document.getElementById('newMsgText');
if (btn && txt) {
txt.textContent = unreadCount + ' nouveau' + (unreadCount > 1 ? 'x' : '') + ' message' + (unreadCount > 1 ? 's' : '');
btn.classList.remove('hidden');
}
}
}
function scrollToBottom() {
var d = document.getElementById('shoutboxDisplay');
d.scrollTop = d.scrollHeight;
autoScroll = true;
unreadCount = 0;
var btn = document.getElementById('newMsgBtn');
if (btn) btn.classList.add('hidden');
}
function updateOnline(users) {
var list = document.getElementById('shoutboxOnlineList');
var countEl = document.getElementById('onlineCountText');
if (countEl) countEl.textContent = (users ? users.length : 0) + ' en ligne';
if (!list) return;
if (!users || users.length === 0) { list.innerHTML = '<div class="text-xs text-gray-600 italic">---</div>'; return; }
list.innerHTML = '';
users.forEach(function(u) {
var el = document.createElement('div');
el.className = 'flex items-center gap-1.5 text-xs text-gray-300 cursor-pointer hover:text-white';
el.onclick = function() { mentionUser(u); };
el.innerHTML = '<span class="w-1.5 h-1.5 rounded-full bg-green-500 flex-shrink-0"></span>' + esc(u);
list.appendChild(el);
});
}
function poll() {
var url = '{{ url_for("shoutbox_messages") }}';
if (lastTimestamp) url += '?since=' + encodeURIComponent(lastTimestamp);
fetch(url)
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.items && data.items.length > 0) {
data.items.forEach(addItem);
if (autoScroll) {
setTimeout(function() {
var d = document.getElementById('shoutboxDisplay');
d.scrollTop = d.scrollHeight;
}, 0);
}
}
if (data.timestamp) lastTimestamp = data.timestamp;
if (data.online_users) updateOnline(data.online_users);
var cnt = document.getElementById('shoutboxCount');
if (cnt) cnt.textContent = '(' + knownIds.size + ')';
})
.catch(function(e) { console.error('Poll error:', e); });
}
function heartbeat() {
fetch('{{ url_for("shoutbox_heartbeat") }}', { method: 'POST', headers: {'Content-Type':'application/json'}, body: '{}' })
.then(function(r) { return r.json(); })
.then(function(d) { updateOnline(d.online_users); })
.catch(function() {});
}
function sendShoutboxMessage(event) {
event.preventDefault();
var input = document.getElementById('shoutboxInput');
var msg = input.value.trim();
if (!msg) return;
var btn = event.target.querySelector('button[type="submit"]');
btn.disabled = true;
fetch('{{ url_for("shoutbox_send") }}', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({message: msg}) })
.then(function(r) { return r.json(); })
.then(function(d) { if (d.success) { input.value = ''; poll(); } })
.catch(function() {})
.finally(function() { btn.disabled = false; });
}
document.getElementById('shoutboxDisplay').addEventListener('scroll', function() {
autoScroll = this.scrollHeight - this.scrollTop <= this.clientHeight + 30;
if (autoScroll && unreadCount > 0) {
unreadCount = 0;
var btn = document.getElementById('newMsgBtn');
if (btn) btn.classList.add('hidden');
}
});
shoutboxApplyFontSize();
shoutboxUpdateSoundUI();
setInterval(poll, 3000);
poll();
setInterval(heartbeat, 10000);
heartbeat();
</script>
</body>
</html>
+327 -22
View File
@@ -1,39 +1,344 @@
<!DOCTYPE html> <!DOCTYPE html>
<html color-mode="user"> <html lang="fr" class="h-full">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge"> <meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes">
<title>Mamie Henriette</title> <title>Mamie Henriette</title>
<link rel="stylesheet" href="/static/css/mvp.css" /> <script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/static/css/style.css" /> <script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
},
accent: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
}
}
}
}
}
</script>
<link rel="icon" href="/static/ico/favicon.ico" type="image/x-icon"> <link rel="icon" href="/static/ico/favicon.ico" type="image/x-icon">
<link rel="shortcut icon" href="/static/ico/favicon.ico" type="image/x-icon"> <link rel="shortcut icon" href="/static/ico/favicon.ico" type="image/x-icon">
<style>
/* Animations personnalisées */
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
/* Scrollbar personnalisée */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
.dark ::-webkit-scrollbar-thumb { background: #475569; }
.dark ::-webkit-scrollbar-thumb:hover { background: #64748b; }
</style>
</head> </head>
<body> <body class="h-full bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors duration-200">
<header> <!-- Navbar -->
<nav> <nav class="fixed top-0 left-0 right-0 z-50 bg-white dark:bg-gray-800 shadow-md border-b border-gray-200 dark:border-gray-700">
<a href="/"><img src="/static/ico/favicon.ico"></a> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<ul> <div class="flex items-center justify-between h-16">
<li><a href="/live-alert">Alerte live</a></li> <!-- Logo -->
<li><a href="/commandes">Commandes</a></li> <a href="/" class="flex items-center gap-3 group">
<li><a href="/humeurs">Humeurs</a></li> <img src="/static/ico/favicon.ico" alt="Mamie Henriette" class="w-10 h-10 rounded-full ring-2 ring-slate-200 dark:ring-slate-600 group-hover:ring-slate-300 dark:group-hover:ring-slate-500 transition-all">
<li><a href="/moderation">Modération</a></li> <span class="font-bold text-xl text-slate-800 dark:text-white hidden sm:block">Mamie Henriette</span>
<li><a href="/protondb">ProtonDB</a></li> </a>
<li><a href="/configurations">Configurations</a></li>
</ul> <!-- Navigation Desktop -->
<div class="hidden md:flex items-center gap-1">
<!-- Discord (sous-menus) -->
<div class="relative group">
<button type="button" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all flex items-center gap-1.5">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
Discord
<svg class="w-4 h-4 transition-transform group-hover:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
</button>
<div class="absolute left-0 top-full pt-1 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 min-w-[200px]">
<a href="/humeurs" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
Humeur
</a>
<a href="/live-alert" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
Notification Twitch
</a>
<a href="{{ url_for('open_twitch_events') }}" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path></svg>
Événements Twitch (sub, raid, clip)
</a>
<a href="/youtube" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
Notification YouTube
</a>
<a href="/protondb" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z"></path></svg>
ProtonDB
</a>
<a href="{{ url_for('openFreeLoot') }}" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<span class="text-lg">🎁</span>
FreeLoot
</a>
<a href="{{ url_for('openPatreon') }}" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M14.82 2.41c3.96 0 7.18 3.24 7.18 7.21 0 3.96-3.22 7.18-7.18 7.18-2.56 0-4.81-1.34-6.09-3.36L2 20.6h3.12l2.6-2.63 1.38 2.63h3.36l-2.75-5.23c.27-.39.51-.8.71-1.24l4.82 6.47h3.76l-6.47-8.67c.19-.76.29-1.55.29-2.37 0-1.32-.28-2.58-.78-3.71h3.63L14.82 2.41zM14.82 4.92c2.57 0 4.67 2.09 4.67 4.7 0 2.57-2.1 4.67-4.67 4.67-2.58 0-4.68-2.1-4.68-4.67 0-2.61 2.1-4.7 4.68-4.7z"/></svg>
Patreon
</a>
<div class="border-t border-gray-200 dark:border-gray-700 my-1"></div>
<a href="/commandes" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
Commandes
</a>
<a href="/moderation" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
Modération
</a>
<a href="/configurations#auto-rooms" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v3m0 0v-3a7 7 0 017-7"></path></svg>
Auto Rooms
</a>
</div>
</div>
</div>
<!-- Twitch (futur bot) -->
<div class="relative group">
<button type="button" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all flex items-center gap-1.5">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M11.571 4.714h1.715v5.143H11.57l-.002-5.143zm3.43 0H16.714v5.143H15V4.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0H6zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714v9.429z"/></svg>
Twitch
<svg class="w-4 h-4 transition-transform group-hover:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
</button>
<div class="absolute left-0 top-full pt-1 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 min-w-[200px]">
<a href="/live-alert" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
Alerte live
</a>
<a href="{{ url_for('open_twitch_events') }}" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path></svg>
Événements (sub, raid, clip)
</a>
<a href="/announcements" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"></path></svg>
Annonces
</a>
<a href="/twitch-moderation" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
Moderation
</a>
<a href="/link-filter" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>
Filtre de liens
</a>
</div>
</div>
</div>
<!-- Configuration locale -->
<a href="/configurations" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
Configuration
</a>
{% if current_user.is_authenticated and current_user_level >= 5 %}
<a href="{{ url_for('users_list') }}" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
Utilisateurs
</a>
<a href="{{ url_for('settings') }}" class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
Paramètres
</a>
{% endif %}
</div>
<!-- Actions -->
<div class="flex items-center gap-2">
{% if current_user.is_authenticated %}
<span class="hidden sm:inline text-sm text-gray-600 dark:text-gray-400" title="Rôle : {{ current_user.role }}">{{ current_user.username }}</span>
<a href="{{ url_for('logout') }}" class="px-3 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">Déconnexion</a>
{% else %}
<a href="{{ url_for('login') }}" class="px-3 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">Connexion</a>
{% if registration_enabled %}
<a href="{{ url_for('register') }}" class="px-3 py-2 rounded-lg text-sm font-medium bg-primary-600 dark:bg-primary-500 text-white hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">Créer un compte</a>
{% endif %}
{% endif %}
<!-- Dark Mode Toggle -->
<button onclick="toggleDarkMode()" class="p-2 rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 transition-all" title="Mode sombre">
<svg class="w-5 h-5 hidden dark:block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
<svg class="w-5 h-5 block dark:hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path></svg>
</button>
<!-- Mobile Menu Button -->
<button onclick="toggleMobileMenu()" class="md:hidden p-2 rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
</button>
</div>
</div>
</div>
<!-- Mobile Menu -->
<div id="mobile-menu" class="hidden md:hidden border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<div class="px-4 py-3 space-y-1">
<a href="/live-alert" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
Alerte live
</a>
<a href="/announcements" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"></path></svg>
Annonces Twitch
</a>
<a href="/twitch-moderation" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
Moderation Twitch
</a>
<a href="/link-filter" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>
Filtre de liens
</a>
<a href="{{ url_for('open_twitch_events') }}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path></svg>
Événements Twitch (sub, raid, clip)
</a>
<a href="/youtube" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
YouTube
</a>
<a href="/commandes" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
Commandes
</a>
<a href="/humeurs" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
Humeurs
</a>
<a href="/moderation" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
Modération
</a>
<a href="/configurations#auto-rooms" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v3m0 0v-3a7 7 0 017-7"></path></svg>
Auto Rooms
</a>
<a href="/protondb" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z"></path></svg>
ProtonDB
</a>
<a href="{{ url_for('openFreeLoot') }}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<span class="text-lg">🎁</span>
FreeLoot
</a>
<a href="{{ url_for('openPatreon') }}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M14.82 2.41c3.96 0 7.18 3.24 7.18 7.21 0 3.96-3.22 7.18-7.18 7.18-2.56 0-4.81-1.34-6.09-3.36L2 20.6h3.12l2.6-2.63 1.38 2.63h3.36l-2.75-5.23c.27-.39.51-.8.71-1.24l4.82 6.47h3.76l-6.47-8.67c.19-.76.29-1.55.29-2.37 0-1.32-.28-2.58-.78-3.71h3.63L14.82 2.41zM14.82 4.92c2.57 0 4.67 2.09 4.67 4.7 0 2.57-2.1 4.67-4.67 4.67-2.58 0-4.68-2.1-4.68-4.67 0-2.61 2.1-4.7 4.68-4.7z"/></svg>
Patreon
</a>
<a href="/configurations" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
Configurations
</a>
{% if current_user.is_authenticated and current_user_level >= 5 %}
<a href="{{ url_for('users_list') }}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
Utilisateurs
</a>
<a href="{{ url_for('settings') }}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
Paramètres
</a>
{% endif %}
{% if current_user.is_authenticated %}
<a href="{{ url_for('logout') }}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all border-t border-gray-200 dark:border-gray-700 mt-2 pt-4">
Déconnexion ({{ current_user.username }})
</a>
{% else %}
<a href="{{ url_for('login') }}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all border-t border-gray-200 dark:border-gray-700 mt-2 pt-4">Connexion</a>
{% if registration_enabled %}
<a href="{{ url_for('register') }}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-primary-600 dark:text-primary-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">Créer un compte</a>
{% endif %}
{% endif %}
</div>
</div>
</nav> </nav>
</header>
<main> <!-- Main Content -->
<main class="pt-20 pb-12 min-h-screen">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="fade-in">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div>
</div>
</main> </main>
<footer>
<hr> <!-- Footer -->
<p><a href="https://github.com/skylanix/MamieHenriette" target="_blank">MamieHenriette</a> créé par la communauté <a href="https://discord.com/invite/UwAPqMJnx3" target="_blank">Discord</a> de <a href="https://www.youtube.com/@513v3" target="_blank">573v3</a> - Projet open source sous licence <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank">AGPLv3</a></p> <footer class="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex flex-col sm:flex-row items-center justify-between gap-4">
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<img src="/static/ico/favicon.ico" alt="" class="w-6 h-6 rounded-full">
<span>Mamie Henriette</span>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 text-center">
Créé par la communauté
<a href="https://discord.com/invite/UwAPqMJnx3" target="_blank" class="text-primary-600 dark:text-primary-400 hover:underline">Discord</a>
de
<a href="https://www.youtube.com/@513v3" target="_blank" class="text-primary-600 dark:text-primary-400 hover:underline">573v3</a>
</p>
<div class="flex items-center gap-4">
<a href="https://github.com/skylanix/MamieHenriette" target="_blank" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"></path></svg>
</a>
<span class="text-xs text-gray-400 dark:text-gray-500">AGPLv3</span>
</div>
</div>
</div>
</footer> </footer>
<script>
// Dark mode
function toggleDarkMode() {
document.documentElement.classList.toggle('dark');
localStorage.setItem('darkMode', document.documentElement.classList.contains('dark'));
}
// Initialize dark mode from preference
if (localStorage.getItem('darkMode') === 'true' ||
(!localStorage.getItem('darkMode') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
// Mobile menu
function toggleMobileMenu() {
document.getElementById('mobile-menu').classList.toggle('hidden');
}
</script>
</body> </body>
</html> </html>
+83 -26
View File
@@ -1,35 +1,92 @@
{% extends "template.html" %} {% extends "template.html" %}
{% block content %} {% block content %}
<h1>Procédure de configuration de Twitch</h1> <div class="mb-6">
<p> <h1 class="text-2xl font-semibold text-slate-800 dark:text-white mb-1">Configuration Twitch</h1>
<strong>Avant toute chose, activez l'authentification à deux facteurs (2FA) :</strong> <p class="text-sm text-slate-600 dark:text-slate-400">
<a href="https://help.twitch.tv/s/article/two-factor-authentication?language=en_US" target="_blank">Guide officiel Guide étape par étape pour configurer l'API Twitch.
Twitch pour la 2FA</a> </p>
</p> </div>
<p>
Rendez-vous sur <a href="https://dev.twitch.tv/console" target="_blank">la console d'applications Twitch</a> et
ajoutez une application. Renseignez :
<ul>
<li>URL de redirection : {{token_redirect_url}}</li>
<li>Catégorie : Chat Bot</li>
</ul>
</p>
<img src="/static/img/twitch-api-01.jpg"> <div class="space-y-4">
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<div class="flex items-center gap-3">
<span class="w-6 h-6 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-slate-600 dark:text-slate-400 text-xs font-medium">1</span>
<h2 class="font-medium text-slate-800 dark:text-white">Activer l'authentification à deux facteurs (2FA)</h2>
</div>
</div>
<div class="p-5">
<p class="text-sm text-slate-600 dark:text-slate-400 mb-4">
Avant de créer une application Twitch, vous devez activer la 2FA sur votre compte.
</p>
<a href="https://help.twitch.tv/s/article/two-factor-authentication?language=en_US" target="_blank" class="inline-flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-white">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>
Guide officiel Twitch
</a>
</div>
</div>
<p> <div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
Créez le bot. Puis, de retour à la liste, éditez-le en cliquant sur Gérer. Puis cliquez sur <strong>Nouveau <div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
Secret</strong>. Vous trouverez ici le <strong>Client ID</strong> et le <strong>Client Secret</strong>. <div class="flex items-center gap-3">
</p> <span class="w-6 h-6 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-slate-600 dark:text-slate-400 text-xs font-medium">2</span>
<h2 class="font-medium text-slate-800 dark:text-white">Créer une application Twitch</h2>
</div>
</div>
<div class="p-5 space-y-4">
<p class="text-sm text-slate-600 dark:text-slate-400">
Rendez-vous sur la console Twitch et créez une nouvelle application :
</p>
<a href="https://dev.twitch.tv/console" target="_blank" class="inline-flex items-center gap-2 px-3 py-1.5 bg-slate-800 dark:bg-slate-700 text-white text-sm rounded-lg hover:bg-slate-700 dark:hover:bg-slate-600 transition-colors">
Console Twitch
</a>
<img src="/static/img/twitch-api-02.jpg"> <div class="bg-slate-50 dark:bg-slate-700/50 rounded-lg p-4 space-y-2 text-sm">
<div><span class="text-slate-500 dark:text-slate-400">URL de redirection :</span> <code class="ml-1 px-1.5 py-0.5 bg-slate-200 dark:bg-slate-600 rounded text-xs">{{ token_redirect_url }}</code></div>
<div><span class="text-slate-500 dark:text-slate-400">Catégorie :</span> <span class="ml-1 text-slate-800 dark:text-white">Chat Bot</span></div>
</div>
<p> <div class="rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700">
Ensuite, retournez sur la page de <a href="{{url_for('openConfigurations')}}">Configuration</a>, après avoir <img src="/static/img/twitch-api-01.jpg" alt="Création d'application Twitch" class="w-full">
enregistré le <strong>Client ID</strong> et le <strong>Client Secret</strong>, cliquez sur le lien <strong>Obtenir </div>
token et refresh token</strong>. Si tout se passe bien les champs <strong>Access Token</strong> et </div>
<strong>Refresh Token</strong> sont remplis. </div>
</p>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<div class="flex items-center gap-3">
<span class="w-6 h-6 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-slate-600 dark:text-slate-400 text-xs font-medium">3</span>
<h2 class="font-medium text-slate-800 dark:text-white">Récupérer les identifiants</h2>
</div>
</div>
<div class="p-5 space-y-4">
<p class="text-sm text-slate-600 dark:text-slate-400">
Cliquez sur <strong class="text-slate-800 dark:text-white">Gérer</strong> puis <strong class="text-slate-800 dark:text-white">Nouveau Secret</strong> pour obtenir le Client ID et Client Secret.
</p>
<div class="rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700">
<img src="/static/img/twitch-api-02.jpg" alt="Récupération des identifiants" class="w-full">
</div>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<div class="flex items-center gap-3">
<span class="w-6 h-6 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-slate-600 dark:text-slate-400 text-xs font-medium">4</span>
<h2 class="font-medium text-slate-800 dark:text-white">Configurer Mamie Henriette</h2>
</div>
</div>
<div class="p-5 space-y-4">
<ol class="list-decimal list-inside space-y-1 text-sm text-slate-600 dark:text-slate-400">
<li>Entrez le Client ID et Client Secret</li>
<li>Cliquez sur Enregistrer</li>
<li>Cliquez sur "Obtenir token et refresh token"</li>
</ol>
<a href="{{ url_for('openConfigurations') }}" class="inline-flex items-center gap-2 px-3 py-1.5 bg-slate-800 dark:bg-slate-700 text-white text-sm rounded-lg hover:bg-slate-700 dark:hover:bg-slate-600 transition-colors">
Aller à la Configuration
</a>
</div>
</div>
</div>
{% endblock %} {% endblock %}
+117
View File
@@ -0,0 +1,117 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Notifications d'événements Twitch</h1>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p class="text-gray-700 dark:text-gray-300">
Configurez les notifications pour les abonnements, follows, raids et nouveaux clips.
Pour chaque type d'événement vous pouvez activer l'envoi dans le <strong>chat Twitch</strong> et/ou dans un <strong>canal Discord</strong>.
Les événements sub, follow et raid utilisent Twitch EventSub ; les clips sont détectés par vérification périodique.
</p>
</div>
</div>
<form action="{{ url_for('save_twitch_events') }}" method="POST" class="space-y-8">
{% for cfg in configs %}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between flex-wrap gap-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ labels[cfg.event_type] }}</h2>
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="hidden" name="ev_{{ cfg.event_type }}_enable" value="0">
<input type="checkbox" name="ev_{{ cfg.event_type }}_enable" value="1" {{ 'checked' if cfg.enable }}
class="rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Activer</span>
</label>
<a href="{{ url_for('toggle_twitch_event', event_type=cfg.event_type) }}"
class="text-sm {{ 'text-green-600 dark:text-green-400' if cfg.enable else 'text-gray-500' }}">
{{ 'Activé' if cfg.enable else 'Désactivé' }}
</a>
</div>
</div>
<div class="p-6 space-y-6">
<div class="grid md:grid-cols-2 gap-6">
<div class="space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Où notifier</h3>
<label class="flex items-center gap-3 cursor-pointer">
<input type="hidden" name="ev_{{ cfg.event_type }}_notify_twitch_chat" value="0">
<input type="checkbox" name="ev_{{ cfg.event_type }}_notify_twitch_chat" value="1" {{ 'checked' if cfg.notify_twitch_chat }}
class="rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500">
<span class="text-gray-700 dark:text-gray-300">Chat Twitch</span>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input type="hidden" name="ev_{{ cfg.event_type }}_notify_discord" value="0">
<input type="checkbox" name="ev_{{ cfg.event_type }}_notify_discord" value="1" {{ 'checked' if cfg.notify_discord }}
class="rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500">
<span class="text-gray-700 dark:text-gray-300">Discord (canal de notifs)</span>
</label>
<div>
<label for="ev_{{ cfg.event_type }}_discord_channel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Canal Discord</label>
<select name="ev_{{ cfg.event_type }}_discord_channel_id" id="ev_{{ cfg.event_type }}_discord_channel"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="">— Choisir un canal —</option>
{% for ch in channels %}
<option value="{{ ch.id }}" {{ 'selected' if cfg.discord_channel_id == ch.id }}>{{ ch.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="space-y-4">
<div>
<label for="ev_{{ cfg.event_type }}_message_twitch" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Message (chat Twitch)</label>
<input type="text" name="ev_{{ cfg.event_type }}_message_twitch" id="ev_{{ cfg.event_type }}_message_twitch" maxlength="500"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
value="{{ cfg.message_twitch }}" placeholder="Merci {user} !">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Sub/Follow: <code>{user}</code> <code>{user_name}</code>
Raid: <code>{from_broadcaster_name}</code> <code>{viewers}</code>
Clip: <code>{user}</code> <code>{title}</code> <code>{url}</code>
</p>
</div>
<div>
<label for="ev_{{ cfg.event_type }}_message_discord" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Message Discord (optionnel, avant l'embed)</label>
<textarea name="ev_{{ cfg.event_type }}_message_discord" id="ev_{{ cfg.event_type }}_message_discord" rows="2" maxlength="2000"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white resize-y">{{ cfg.message_discord or '' }}</textarea>
</div>
</div>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
<h4 class="font-medium text-gray-800 dark:text-gray-200 mb-3">Embed Discord (optionnel)</h4>
<div class="grid sm:grid-cols-2 gap-4">
<div>
<label for="ev_{{ cfg.event_type }}_embed_title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Titre</label>
<input type="text" name="ev_{{ cfg.event_type }}_embed_title" id="ev_{{ cfg.event_type }}_embed_title" maxlength="256"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
value="{{ cfg.embed_title or '' }}" placeholder="Ex: Nouveau clip">
</div>
<div>
<label for="ev_{{ cfg.event_type }}_embed_color" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Couleur (hex)</label>
<input type="text" name="ev_{{ cfg.event_type }}_embed_color" id="ev_{{ cfg.event_type }}_embed_color" maxlength="6"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono"
value="{{ cfg.embed_color or '9146FF' }}" placeholder="9146FF">
</div>
</div>
<div class="mt-3">
<label for="ev_{{ cfg.event_type }}_embed_description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Description</label>
<textarea name="ev_{{ cfg.event_type }}_embed_description" id="ev_{{ cfg.event_type }}_embed_description" rows="2" maxlength="2000"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white resize-y">{{ cfg.embed_description or '' }}</textarea>
</div>
<label class="flex items-center gap-2 mt-3 cursor-pointer">
<input type="hidden" name="ev_{{ cfg.event_type }}_embed_thumbnail" value="0">
<input type="checkbox" name="ev_{{ cfg.event_type }}_embed_thumbnail" value="1" {{ 'checked' if cfg.embed_thumbnail }}
class="rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500">
<span class="text-sm text-gray-700 dark:text-gray-300">Miniature dans l'embed (clips)</span>
</label>
</div>
</div>
</div>
{% endfor %}
<div class="flex justify-end">
<button type="submit" class="px-6 py-2.5 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Enregistrer tout
</button>
</div>
</form>
{% endblock %}
File diff suppressed because it is too large Load Diff
+292
View File
@@ -0,0 +1,292 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-8">
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 w-12 h-12 rounded-xl bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<svg class="w-7 h-7 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">Gestion des utilisateurs</h1>
<p class="text-gray-600 dark:text-gray-400">Liste des comptes webapp et attribution des rôles</p>
</div>
</div>
<button type="button" onclick="document.getElementById('create-user-modal').classList.remove('hidden')"
class="flex-shrink-0 px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span>Créer un utilisateur</span>
</button>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mb-6 space-y-2">
{% for category, msg in messages %}
<div class="p-4 rounded-lg {% if category == 'error' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800{% else %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-800{% endif %}">
{{ msg }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
{% if users %}
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700">
<tr>
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Utilisateur
</th>
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
E-mail
</th>
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Rôle actuel
</th>
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Inscrit le
</th>
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Modifier le rôle
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for u in users %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center text-white font-semibold">
{{ u.username[0].upper() }}
</div>
<div>
<div class="font-medium text-gray-900 dark:text-white">{{ u.username }}</div>
{% if u.id == current_user.id %}
<span class="text-xs text-primary-600 dark:text-primary-400 font-medium">C'est vous</span>
{% endif %}
</div>
</div>
</td>
<td class="px-6 py-4">
<span class="text-sm text-gray-600 dark:text-gray-400">{{ u.email }}</span>
</td>
<td class="px-6 py-4">
{% set user_role = None %}
{% for r in roles %}
{% if r == u.role %}
{% set user_role = r %}
{% endif %}
{% endfor %}
{% if user_role %}
{% set role_obj = namespace(found=None) %}
{% for role_name in roles %}
{% if role_name == user_role %}
{% for r_obj in roles %}
{# Obtenir l'objet WebappRole complet #}
{% endfor %}
{% endif %}
{% endfor %}
<span class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium whitespace-nowrap"
style="background-color: #6B728020; color: #6B7280;">
<span class="w-2 h-2 rounded-full" style="background-color: #6B7280;"></span>
{{ role_labels.get(u.role, u.role) }}
</span>
{% else %}
<span class="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300">
{{ role_labels.get(u.role, u.role) }}
</span>
{% endif %}
</td>
<td class="px-6 py-4">
<span class="text-sm text-gray-500 dark:text-gray-400">
{% if u.created_at %}{{ u.created_at.strftime('%d/%m/%Y à %H:%M') }}{% else %}—{% endif %}
</span>
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-2">
<form action="{{ url_for('users_set_role', user_id=u.id) }}" method="post" class="flex items-center gap-2">
<select name="role" class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500 transition-colors"
{% if u.id == current_user.id %}disabled title="Vous ne pouvez pas modifier votre propre rôle"{% endif %}>
{% for r in roles %}
<option value="{{ r }}" {% if u.role == r %}selected{% endif %}>{{ role_labels.get(r, r) }}</option>
{% endfor %}
</select>
<button type="submit"
{% if u.id == current_user.id %}disabled{% endif %}
class="px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white text-sm font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
Appliquer
</button>
</form>
{% if u.id != current_user.id %}
<form action="{{ url_for('users_delete', user_id=u.id) }}" method="post" class="inline" onsubmit="return confirm('Êtes-vous sûr de vouloir supprimer cet utilisateur ?');">
<button type="submit" class="px-3 py-2 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm font-medium hover:bg-red-200 dark:hover:bg-red-900/50 transition-colors" title="Supprimer cet utilisateur">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</form>
{% endif %}
</div>
{% if u.id == current_user.id %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Protection: modification/suppression impossible</p>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-12 text-center">
<svg class="w-16 h-16 mx-auto text-gray-400 dark:text-gray-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<p class="text-gray-500 dark:text-gray-400 text-lg">Aucun utilisateur enregistré</p>
</div>
{% endif %}
</div>
<!-- Légende des rôles -->
{% if users %}
<div class="mt-6 bg-blue-50 dark:bg-blue-900/20 rounded-xl border border-blue-200 dark:border-blue-800 p-6">
<h3 class="text-lg font-semibold text-blue-900 dark:text-blue-200 mb-4 flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Hiérarchie des rôles</span>
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{% for role_name in roles %}
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 border border-blue-100 dark:border-blue-900">
<div class="flex items-center gap-2 mb-1">
<span class="w-3 h-3 rounded-full" style="background-color: #6B7280;"></span>
<span class="font-medium text-gray-900 dark:text-white text-sm">{{ role_labels.get(role_name, role_name) }}</span>
</div>
<p class="text-xs text-gray-600 dark:text-gray-400 ml-5">
{% if role_name == 'viewer_twitch' %}
Accès minimal, consultation uniquement
{% elif role_name == 'utilisateur_discord' %}
Modification de contenu basique
{% elif role_name == 'moderateur_discord' %}
Outils de modération Discord
{% elif role_name == 'expert_discord' %}
Gestion avancée du contenu
{% elif role_name == 'moderateur_twitch' %}
Outils de modération Twitch
{% elif role_name == 'super_administrateur' %}
Accès complet au système
{% else %}
Rôle personnalisé
{% endif %}
</p>
</div>
{% endfor %}
</div>
<div class="mt-4 p-3 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<p class="text-sm text-blue-800 dark:text-blue-200">
<strong>💡 Conseil :</strong> Vous pouvez personnaliser les rôles et leurs permissions dans la page
<a href="{{ url_for('settings') }}" class="underline hover:text-blue-900 dark:hover:text-blue-100">Paramètres</a>.
</p>
</div>
</div>
{% endif %}
<!-- Modal de création d'utilisateur -->
<div id="create-user-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" onclick="document.getElementById('create-user-modal').classList.add('hidden')"></div>
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<form action="{{ url_for('users_create') }}" method="post">
<div class="bg-white dark:bg-gray-800 px-6 pt-6 pb-4">
<div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center">
<svg class="w-6 h-6 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">Créer un utilisateur</h3>
</div>
<button type="button" onclick="document.getElementById('create-user-modal').classList.add('hidden')"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Nom d'utilisateur <span class="text-red-500">*</span>
</label>
<input type="text" id="username" name="username" required minlength="3"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500"
placeholder="ex: john_doe">
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Adresse e-mail <span class="text-red-500">*</span>
</label>
<input type="email" id="email" name="email" required
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500"
placeholder="ex: john@example.com">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Mot de passe <span class="text-red-500">*</span>
</label>
<input type="password" id="password" name="password" required minlength="8"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500"
placeholder="Au moins 8 caractères">
</div>
<div>
<label for="password_confirm" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Confirmer le mot de passe <span class="text-red-500">*</span>
</label>
<input type="password" id="password_confirm" name="password_confirm" required minlength="8"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500"
placeholder="Retapez le mot de passe">
</div>
<div>
<label for="role" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Rôle <span class="text-red-500">*</span>
</label>
<select id="role" name="role" required
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500">
{% for r in roles %}
<option value="{{ r }}">{{ role_labels.get(r, r) }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 px-6 py-4 flex gap-3 justify-end">
<button type="button" onclick="document.getElementById('create-user-modal').classList.add('hidden')"
class="px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 font-medium hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors">
Annuler
</button>
<button type="submit"
class="px-4 py-2 rounded-lg bg-primary-600 dark:bg-primary-500 text-white font-medium hover:bg-primary-700 dark:hover:bg-primary-600 transition-colors">
Créer l'utilisateur
</button>
</div>
</form>
</div>
</div>
</div>
<style>
/* Animation au survol des lignes */
tbody tr {
transition: all 0.2s ease;
}
</style>
{% endblock %}
+135
View File
@@ -0,0 +1,135 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-8">
<div class="flex items-center justify-between mb-4">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Historique des vidéos YouTube</h1>
<a href="{{ url_for('openYouTube') }}" class="px-4 py-2 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 font-medium rounded-lg transition-colors text-sm flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path></svg>
Retour
</a>
</div>
{% if msg %}
<div id="alert-msg" class="mb-4 p-4 rounded-lg {{ 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300' if msg_type == 'error' else 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-300' }}">
{{ msg }}
</div>
<script>
setTimeout(function() {
var el = document.getElementById('alert-msg');
if (el) el.style.display = 'none';
}, 5000);
</script>
{% endif %}
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-6">
<p class="text-gray-700 dark:text-gray-300">
Historique des vidéos détectées par le bot. Les vidéos non notifiées peuvent être envoyées manuellement sur Discord.
</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ total }} vidéo{{ 's' if total > 1 else '' }} au total.</p>
</div>
<div class="flex flex-wrap gap-2 mb-6" aria-label="Filtrer l'historique des vidéos">
<a href="{{ url_for('youtubeHistory') }}" class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {{ 'bg-red-600 text-white' if history_filter == 'all' else 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300' }}">
Toutes
</a>
<a href="{{ url_for('youtubeHistory', filter='video') }}" class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {{ 'bg-red-600 text-white' if history_filter == 'video' else 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300' }}">
Vidéos
</a>
<a href="{{ url_for('youtubeHistory', filter='short') }}" class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {{ 'bg-red-600 text-white' if history_filter == 'short' else 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300' }}">
Shorts
</a>
</div>
</div>
{% if history %}
<div class="space-y-4">
{% for entry in history %}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden hover:shadow-md transition-shadow">
<div class="flex flex-col sm:flex-row">
{% if entry.thumbnail %}
<a href="{{ entry.url }}" target="_blank" class="shrink-0 sm:w-48 h-28 overflow-hidden bg-gray-100 dark:bg-gray-700">
<img src="{{ entry.thumbnail }}" alt="" class="w-full h-full object-cover">
</a>
{% endif %}
<div class="flex-1 p-4 flex flex-col justify-between min-w-0">
<div>
<div class="flex items-start justify-between gap-3 mb-1">
<a href="{{ entry.url }}" target="_blank" class="text-base font-semibold text-gray-900 dark:text-white hover:text-red-600 dark:hover:text-red-400 transition-colors truncate">
{{ entry.title or 'Sans titre' }}
</a>
<div class="shrink-0 flex items-center gap-2">
{% if entry.is_short %}
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300">Short</span>
{% endif %}
{% if entry.notified %}
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
Notifié
</span>
{% else %}
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
Non notifié
</span>
{% endif %}
</div>
</div>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-500 dark:text-gray-400">
<span>{{ entry.channel_name or 'Inconnu' }}</span>
{% if entry.published_at %}
<span>{{ entry.published_at[:10] }}</span>
{% endif %}
{% if notification_map.get(entry.notification_id) %}
<span class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded font-mono">{{ notification_map[entry.notification_id].channel_id }}</span>
{% endif %}
</div>
</div>
<div class="flex items-center gap-2 mt-3">
<a href="{{ entry.url }}" target="_blank" class="px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors">
Voir sur YouTube
</a>
<form action="{{ url_for('forceYouTubeNotify', history_id=entry.id) }}" method="POST" class="inline"
onsubmit="return confirm('Envoyer la notification Discord pour cette vidéo ?')">
<button type="submit" class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex items-center gap-1
{% if entry.notified %}
text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700
{% else %}
text-white bg-red-600 hover:bg-red-700
{% endif %}">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path></svg>
{{ 'Re-notifier' if entry.notified else 'Forcer la notification' }}
</button>
</form>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% if total_pages > 1 %}
<div class="mt-8 flex items-center justify-center gap-2">
{% if page > 1 %}
<a href="{{ url_for('youtubeHistory', page=page-1, filter=history_filter) }}" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors text-sm">
Précédent
</a>
{% endif %}
<span class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">
Page {{ page }} / {{ total_pages }}
</span>
{% if page < total_pages %}
<a href="{{ url_for('youtubeHistory', page=page+1, filter=history_filter) }}" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors text-sm">
Suivant
</a>
{% endif %}
</div>
{% endif %}
{% else %}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-8 text-center">
<svg class="w-12 h-12 mx-auto text-gray-400 dark:text-gray-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
<p class="text-gray-500 dark:text-gray-400">Aucune vidéo détectée pour le moment. L'historique se remplira au fur et à mesure des vérifications.</p>
</div>
{% endif %}
{% endblock %}
+346
View File
@@ -0,0 +1,346 @@
{% extends "template.html" %}
{% block content %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Notifications YouTube</h1>
{% if msg %}
<div id="alert-msg" class="mb-4 p-4 rounded-lg {{ 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300' if msg_type == 'error' else 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-300' }}">
{{ msg }}
</div>
<script>
setTimeout(function() {
var el = document.getElementById('alert-msg');
if (el) el.style.display = 'none';
}, 5000);
</script>
{% endif %}
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 flex items-center justify-between">
<p class="text-gray-700 dark:text-gray-300">
Liste des chaînes YouTube surveillées pour les notifications de nouvelles vidéos.
Le bot vérifie toutes les 5 minutes les nouvelles vidéos des chaînes en dessous.
Quand une nouvelle vidéo est détectée, le bot enverra une notification sur Discord.
</p>
<a href="{{ url_for('youtubeHistory') }}" class="ml-4 shrink-0 px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-lg transition-colors flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
Historique
</a>
</div>
</div>
{% if not notification %}
<div class="mb-8">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Notifications configurées</h2>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Chaîne YouTube</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Canal Discord</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Type</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Message</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for notification in notifications %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<code class="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-red-600 dark:text-red-400 font-mono text-sm">{{ notification.channel_id }}</code>
</td>
<td class="px-6 py-4 whitespace-nowrap text-gray-700 dark:text-gray-300">{{ notification.notify_channel_name }}</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs font-medium rounded-full
{% if notification.video_type == 'all' %}bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300
{% elif notification.video_type == 'video' %}bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300
{% else %}bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300{% endif %}">
{% if notification.video_type == 'all' %}Toutes
{% elif notification.video_type == 'video' %}Vidéos
{% else %}Shorts{% endif %}
</span>
</td>
<td class="px-6 py-4 text-gray-600 dark:text-gray-400 max-w-xs truncate">{{ notification.message }}</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<div class="flex items-center justify-center gap-2">
<a href="{{ url_for('toggleYouTube', id = notification.id) }}"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
title="{{ 'Désactiver' if notification.enable else 'Activer' }}">
{{ '✅' if notification.enable else '❌' }}
</a>
<a href="{{ url_for('openEditYouTube', id = notification.id) }}"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors text-blue-600 dark:text-blue-400"
title="Modifier">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
</a>
<a href="{{ url_for('delYouTube', id = notification.id) }}"
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette notification ?')"
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors text-red-600 dark:text-red-400"
title="Supprimer">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
</a>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
Aucune notification configurée. Ajoutez-en une ci-dessous.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
{{ 'Modifier la notification' if notification else 'Ajouter une notification YouTube' }}
</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div>
<form id="youtube-form" action="{{ url_for('submitEditYouTube', id = notification.id) if notification else url_for('addYouTube') }}" method="POST" class="space-y-6">
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Configuration de base</h3>
<div>
<label for="channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Lien ou ID de la chaîne YouTube</label>
<input name="channel_id" id="channel_id" type="text" maxlength="256" required
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all"
placeholder="https://www.youtube.com/@513v3 ou UC..."
value="{{notification.channel_id if notification}}"/>
</div>
<div>
<label for="notify_channel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal de notification Discord</label>
<select name="notify_channel" id="notify_channel"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all">
{% for channel in channels %}
<option value="{{channel.id}}" {% if notification and notification.notify_channel == channel.id %}selected{% endif %}>{{channel.name}}</option>
{% endfor %}
</select>
</div>
<div>
<label for="video_type" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Type de vidéo à notifier</label>
<select name="video_type" id="video_type"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all">
<option value="all" {% if notification and notification.video_type == 'all' %}selected{% endif %}>Toutes (vidéos + shorts)</option>
<option value="video" {% if notification and notification.video_type == 'video' %}selected{% endif %}>Vidéos uniquement</option>
<option value="short" {% if notification and notification.video_type == 'short' %}selected{% endif %}>Shorts uniquement</option>
</select>
</div>
<div>
<label for="message" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Message (optionnel)</label>
<textarea name="message" id="message" rows="2"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all resize-y"
placeholder="Message envoyé avant l'embed">{{notification.message if notification}}</textarea>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
<h3 class="font-medium text-gray-800 dark:text-gray-200">Personnalisation de l'embed Discord</h3>
<div>
<label for="embed_title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Titre de l'embed</label>
<input name="embed_title" id="embed_title" type="text" maxlength="256"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all"
placeholder="{video_title}"
value="{{notification.embed_title if notification}}"/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Variables: {video_title}, {channel_name}, {video_url}, {video_id}</p>
</div>
<div>
<label for="embed_description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description de l'embed</label>
<textarea name="embed_description" id="embed_description" rows="2"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all resize-y"
placeholder="Description optionnelle">{{notification.embed_description if notification}}</textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="embed_color" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Couleur</label>
<div class="flex items-center gap-2">
<input name="embed_color" id="embed_color" type="color"
class="w-12 h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer"
value="#{{notification.embed_color if notification else 'FF0000'}}"/>
<input type="text" id="embed_color_text" maxlength="6"
class="flex-1 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
value="{{notification.embed_color if notification else 'FF0000'}}" placeholder="FF0000"/>
</div>
</div>
<div>
<label for="embed_author_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Nom de l'auteur</label>
<input name="embed_author_name" id="embed_author_name" type="text" maxlength="256"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all"
placeholder="{channel_name}"
value="{{notification.embed_author_name if notification}}"/>
</div>
</div>
<div>
<label for="embed_author_icon" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Icône de l'auteur (URL)</label>
<input name="embed_author_icon" id="embed_author_icon" type="text" maxlength="512"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all"
placeholder="https://www.youtube.com/img/desktop/yt_1200.png"
value="{{notification.embed_author_icon if notification}}"/>
</div>
<div>
<label for="embed_footer" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Pied de page</label>
<input name="embed_footer" id="embed_footer" type="text" maxlength="2048"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-red-500 focus:border-transparent transition-all"
placeholder="Texte optionnel en bas"
value="{{notification.embed_footer if notification}}"/>
</div>
<div class="flex flex-wrap gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="embed_thumbnail" id="embed_thumbnail"
{% if not notification or notification.embed_thumbnail %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-red-600 focus:ring-red-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">Miniature</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="embed_image" id="embed_image"
{% if not notification or notification.embed_image %}checked{% endif %}
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-red-600 focus:ring-red-500 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300">Image principale</span>
</label>
</div>
</div>
<div class="flex items-center gap-4">
<button type="submit"
class="px-6 py-2.5 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
{{ 'Enregistrer' if notification else 'Ajouter la notification' }}
</button>
{% if notification %}
<a href="{{ url_for('openYouTube') }}"
class="px-6 py-2.5 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 font-medium rounded-lg transition-colors">
Annuler
</a>
{% endif %}
</div>
</form>
</div>
<div>
<h3 class="font-medium text-gray-800 dark:text-gray-200 mb-4">Prévisualisation de l'embed Discord</h3>
<div id="embed-preview" class="bg-[#2f3136] rounded p-4 font-sans text-[#dcddde] max-w-xl border-l-4" style="border-left-color: #FF0000;">
<div id="embed-author" class="flex items-center mb-2 text-sm">
<img id="embed-author-icon" src="https://www.youtube.com/img/desktop/yt_1200.png" class="w-5 h-5 rounded-full mr-2" onerror="this.style.display='none'"/>
<span id="embed-author-name" class="font-semibold">Nom de la chaîne</span>
</div>
<a id="embed-title" href="#" class="text-[#00aff4] no-underline text-base font-semibold block mb-2">Titre de la vidéo</a>
<div id="embed-description" class="text-sm leading-relaxed mb-2 text-[#dcddde]"></div>
<div id="embed-thumbnail-container" class="my-2">
<img id="embed-thumbnail" src="" class="max-w-[80px] max-h-[80px] rounded float-right ml-4 hidden"/>
</div>
<div id="embed-image-container" class="mt-4">
<img id="embed-image" src="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" class="max-w-full rounded hidden"/>
</div>
<div id="embed-footer" class="mt-2 text-xs text-[#72767d]"></div>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">Cette prévisualisation est approximative.</p>
<div class="mt-6 bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<h4 class="font-medium text-gray-800 dark:text-gray-200 mb-2">Variables disponibles</h4>
<ul class="text-sm text-gray-600 dark:text-gray-400 space-y-1">
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{channel_name}</code> — Nom de la chaîne</li>
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{video_title}</code> — Titre de la vidéo</li>
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{video_url}</code> — Lien vers la vidéo</li>
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{video_id}</code> — ID de la vidéo</li>
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{thumbnail}</code> — URL de la miniature</li>
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{published_at}</code> — Date de publication</li>
<li><code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-600 rounded">{is_short}</code> — True si c'est un short</li>
</ul>
</div>
</div>
</div>
</div>
<script>
function formatText(text, vars) {
if (!text) return '';
return text.replace(/\{(\w+)\}/g, function(match, key) {
return vars[key] || match;
});
}
function updatePreview() {
const embedTitle = document.getElementById('embed_title').value || '{video_title}';
const embedDescription = document.getElementById('embed_description').value || '';
const embedColor = document.getElementById('embed_color_text').value || 'FF0000';
const embedAuthorName = document.getElementById('embed_author_name').value || '{channel_name}';
const embedAuthorIcon = document.getElementById('embed_author_icon').value || 'https://www.youtube.com/img/desktop/yt_1200.png';
const embedFooter = document.getElementById('embed_footer').value || '';
const embedThumbnail = document.getElementById('embed_thumbnail').checked;
const embedImage = document.getElementById('embed_image').checked;
const vars = {
video_title: 'Nouvelle vidéo de test',
channel_name: 'Ma Chaîne YouTube',
video_url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
video_id: 'dQw4w9WgXcQ',
thumbnail: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg',
published_at: '2026-01-25T12:00:00Z',
is_short: false
};
document.getElementById('embed-title').textContent = formatText(embedTitle, vars);
document.getElementById('embed-title').href = vars.video_url;
document.getElementById('embed-description').textContent = formatText(embedDescription, vars);
document.getElementById('embed-author-name').textContent = formatText(embedAuthorName, vars);
document.getElementById('embed-author-icon').src = embedAuthorIcon;
document.getElementById('embed-footer').textContent = formatText(embedFooter, vars);
document.getElementById('embed-preview').style.borderLeftColor = '#' + embedColor;
if (embedThumbnail) {
document.getElementById('embed-thumbnail').src = vars.thumbnail;
document.getElementById('embed-thumbnail').style.display = 'block';
} else {
document.getElementById('embed-thumbnail').style.display = 'none';
}
if (embedImage) {
document.getElementById('embed-image').src = vars.thumbnail;
document.getElementById('embed-image').style.display = 'block';
} else {
document.getElementById('embed-image').style.display = 'none';
}
}
document.getElementById('embed_color').addEventListener('input', function(e) {
document.getElementById('embed_color_text').value = e.target.value.substring(1).toUpperCase();
updatePreview();
});
document.getElementById('embed_color_text').addEventListener('input', function(e) {
const val = e.target.value.replace(/[^0-9A-Fa-f]/g, '').substring(0, 6);
e.target.value = val;
if (val.length === 6) {
document.getElementById('embed_color').value = '#' + val;
updatePreview();
}
});
const formFields = ['embed_title', 'embed_description', 'embed_author_name', 'embed_author_icon', 'embed_footer', 'embed_thumbnail', 'embed_image'];
formFields.forEach(field => {
const el = document.getElementById(field);
if (el) {
el.addEventListener('input', updatePreview);
el.addEventListener('change', updatePreview);
}
});
updatePreview();
</script>
{% endblock %}
+32 -11
View File
@@ -1,6 +1,7 @@
import asyncio
import logging import logging
from flask import render_template, request, redirect, url_for from flask import render_template, request, redirect, url_for, flash
from twitchAPI.twitch import Twitch from twitchAPI.twitch import Twitch
from twitchAPI.type import TwitchAPIException from twitchAPI.type import TwitchAPIException
from twitchAPI.oauth import UserAuthenticator from twitchAPI.oauth import UserAuthenticator
@@ -9,42 +10,62 @@ from database import db
from database.helpers import ConfigurationHelper from database.helpers import ConfigurationHelper
from twitchbot import USER_SCOPE from twitchbot import USER_SCOPE
from webapp import webapp from webapp import webapp
from webapp.auth import require_page
auth: UserAuthenticator auth: UserAuthenticator
@webapp.route("/configurations/twitch/help") @webapp.route("/configurations/twitch/help")
@require_page("configurations")
def twitchConfigurationHelp(): def twitchConfigurationHelp():
return render_template("twitch-aide.html", token_redirect_url = _buildUrl()) return render_template("twitch-aide.html", token_redirect_url=_buildUrl())
@webapp.route("/configurations/twitch/request-token") @webapp.route("/configurations/twitch/request-token")
async def twitchRequestToken(): @require_page("configurations")
def twitchRequestToken():
global auth global auth
helper = ConfigurationHelper() helper = ConfigurationHelper()
twitch = await Twitch(helper.getValue('twitch_client_id'), helper.getValue('twitch_client_secret')) twitch = Twitch(helper.getValue('twitch_client_id'), helper.getValue('twitch_client_secret'))
auth = UserAuthenticator(twitch, USER_SCOPE, url=_buildUrl()) auth = UserAuthenticator(twitch, USER_SCOPE, url=_buildUrl())
return redirect(auth.return_auth_url()) return redirect(auth.return_auth_url())
@webapp.route("/configurations/twitch/receive-token") @webapp.route("/configurations/twitch/receive-token")
async def twitchReceiveToken(): def twitchReceiveToken():
global auth global auth
state = request.args.get('state') state = request.args.get('state')
code = request.args.get('code') code = request.args.get('code')
if state != auth.state :
logging('bad returned state') logging.info("Callback Twitch reçu - state: %s, code: %s", state, code is not None)
if not hasattr(auth, 'state') or auth is None:
logging.error('Objet auth non initialisé - veuillez réessayer')
return redirect(url_for('openConfigurations')) return redirect(url_for('openConfigurations'))
if code == None :
logging('no returned state') if state != auth.state:
logging.error('State invalide - attendu: %s, reçu: %s', auth.state, state)
return redirect(url_for('openConfigurations'))
if code is None:
logging.error('Pas de code retourné par Twitch')
return redirect(url_for('openConfigurations')) return redirect(url_for('openConfigurations'))
try: try:
token, refresh = await auth.authenticate(user_token=code) token, refresh = asyncio.run(auth.authenticate(user_token=code))
logging.info('Tokens Twitch obtenus avec succès')
helper = ConfigurationHelper() helper = ConfigurationHelper()
helper.createOrUpdate('twitch_access_token', token) helper.createOrUpdate('twitch_access_token', token)
helper.createOrUpdate('twitch_refresh_token', refresh) helper.createOrUpdate('twitch_refresh_token', refresh)
db.session.commit() db.session.commit()
logging.info('Tokens Twitch sauvegardés en base de données')
flash('Token Twitch enregistré. Redémarrez l\'application pour que le bot utilise le nouveau token.', 'success')
except TwitchAPIException as e: except TwitchAPIException as e:
logging(e) logging.error('Erreur API Twitch: %s', e)
flash(f'Erreur API Twitch : {e}', 'error')
except Exception as e:
logging.error('Erreur inattendue: %s', e)
flash(f'Erreur inattendue : {e}', 'error')
return redirect(url_for('openConfigurations')) return redirect(url_for('openConfigurations'))
# hack pas fou mais on estime qu'on sera toujours en ssl en connecté # hack pas fou mais on estime qu'on sera toujours en ssl en connecté
+81
View File
@@ -0,0 +1,81 @@
# Notifications d'événements Twitch : sub, follow, raid, clip (chat + Discord)
from flask import render_template, request, redirect, url_for
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.models import TwitchEventNotification
from discordbot import bot
EVENT_LABELS = {
"sub": "Abonnement (sub)",
"follow": "Nouveau follow",
"raid": "Raid reçu",
"clip": "Nouveau clip",
}
@webapp.route("/twitch-events")
@require_page("twitch_events")
def open_twitch_events():
configs = TwitchEventNotification.query.order_by(TwitchEventNotification.event_type).all()
# S'assurer qu'il existe une config par type
existing = {c.event_type for c in configs}
for ev in ("sub", "follow", "raid", "clip"):
if ev not in existing:
cfg = TwitchEventNotification(
event_type=ev,
message_twitch="Merci {user} !" if ev != "raid" else "Bienvenue aux viewers de {from_broadcaster_name} !",
)
db.session.add(cfg)
configs.append(cfg)
db.session.commit()
channels = bot.getAllTextChannel()
# Nom du canal Discord pour l'affichage
for c in configs:
if c.discord_channel_id:
c.discord_channel_name = next((ch.name for ch in channels if ch.id == c.discord_channel_id), None)
else:
c.discord_channel_name = None
return render_template("twitch-events.html", configs=configs, channels=channels, labels=EVENT_LABELS)
@webapp.route("/twitch-events/save", methods=["POST"])
@require_page("twitch_events")
def save_twitch_events():
if not can_write_page("twitch_events"):
return render_template("403.html"), 403
for ev in ("sub", "follow", "raid", "clip"):
cfg = TwitchEventNotification.query.filter_by(event_type=ev).first()
if not cfg:
cfg = TwitchEventNotification(event_type=ev)
db.session.add(cfg)
prefix = f"ev_{ev}_"
cfg.enable = request.form.get(prefix + "enable") == "1"
cfg.notify_twitch_chat = request.form.get(prefix + "notify_twitch_chat") == "1"
cfg.notify_discord = request.form.get(prefix + "notify_discord") == "1"
ch_id = request.form.get(prefix + "discord_channel_id")
cfg.discord_channel_id = int(ch_id) if ch_id and ch_id.isdigit() else None
cfg.message_twitch = (request.form.get(prefix + "message_twitch") or "").strip()[:500]
cfg.message_discord = (request.form.get(prefix + "message_discord") or "").strip()[:2000] or None
embed_color = (request.form.get(prefix + "embed_color") or "9146FF").strip().lstrip("#")[:6]
cfg.embed_color = embed_color if len(embed_color) == 6 else "9146FF"
cfg.embed_title = (request.form.get(prefix + "embed_title") or "").strip()[:256] or None
cfg.embed_description = (request.form.get(prefix + "embed_description") or "").strip()[:2000] or None
cfg.embed_thumbnail = request.form.get(prefix + "embed_thumbnail") == "1"
db.session.commit()
return redirect(url_for("open_twitch_events"))
@webapp.route("/twitch-events/toggle/<event_type>")
@require_page("twitch_events")
def toggle_twitch_event(event_type):
if not can_write_page("twitch_events"):
return render_template("403.html"), 403
if event_type not in ("sub", "follow", "raid", "clip"):
return redirect(url_for("open_twitch_events"))
cfg = TwitchEventNotification.query.filter_by(event_type=event_type).first()
if cfg:
cfg.enable = not cfg.enable
db.session.commit()
return redirect(url_for("open_twitch_events"))
+676
View File
@@ -0,0 +1,676 @@
from flask import render_template, request, redirect, url_for, jsonify
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.models import Commande, TwitchModerationLog, TwitchLinkFilter, TwitchBannedWord, ModShoutboxMessage
from flask_login import current_user
from database.helpers import ConfigurationHelper
from datetime import datetime, timedelta, timezone
def _format_stream_uptime(started_at_iso):
"""Durée depuis le début du live (texte court pour le panneau)."""
if not started_at_iso:
return None
try:
s = str(started_at_iso).replace("Z", "+00:00")
started = datetime.fromisoformat(s)
if started.tzinfo is None:
started = started.replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
sec = int((now - started).total_seconds())
if sec < 0:
return None
h, sec = divmod(sec, 3600)
m, sec = divmod(sec, 60)
if h > 0:
return f"{h}h {m}min"
if m > 0:
return f"{m} min"
return "< 1 min"
except (ValueError, TypeError):
return None
import asyncio
MODERATION_COMMANDS = [
{
"commands": ["!kick", "!to", "!timeout", "!tm"],
"usage": "!timeout <viewer> [minutes] [raison]",
"description": "Ejection temporaire d'un viewer (3 minutes par defaut) avec raison optionnelle",
"permission": "Moderateur"
},
{
"commands": ["!ban"],
"usage": "!ban <viewer1> [viewer2] ...",
"description": "Bannissement d'un ou plusieurs viewers (max 5)",
"permission": "Moderateur"
},
{
"commands": ["!unban"],
"usage": "!unban <viewer1> [viewer2] ...",
"description": "Debannissement d'un ou plusieurs viewers (max 5)",
"permission": "Moderateur"
},
{
"commands": ["!clean"],
"usage": "!clean [viewer]",
"description": "Nettoyage du chat ou des messages d'un viewer",
"permission": "Moderateur"
},
{
"commands": ["!shieldmode"],
"usage": "!shieldmode <on/off>",
"description": "Active/desactive le mode Shield de Twitch",
"permission": "Moderateur"
},
{
"commands": ["!settitle"],
"usage": "!settitle <titre>",
"description": "Changement du titre du live",
"permission": "Moderateur"
},
{
"commands": ["!setgame", "!setcateg"],
"usage": "!setgame <jeu>",
"description": "Changement du jeu/categorie du live",
"permission": "Moderateur"
},
{
"commands": ["!subon"],
"usage": "!subon",
"description": "Activation du mode abonnes uniquement",
"permission": "Moderateur"
},
{
"commands": ["!suboff"],
"usage": "!suboff",
"description": "Desactivation du mode abonnes uniquement",
"permission": "Moderateur"
},
{
"commands": ["!follon"],
"usage": "!follon [minutes]",
"description": "Activation du mode followers-only",
"permission": "Moderateur"
},
{
"commands": ["!folloff"],
"usage": "!folloff",
"description": "Desactivation du mode followers-only",
"permission": "Moderateur"
},
{
"commands": ["!emoteon"],
"usage": "!emoteon",
"description": "Activation du mode emote-only",
"permission": "Moderateur"
},
{
"commands": ["!emoteoff"],
"usage": "!emoteoff",
"description": "Desactivation du mode emote-only",
"permission": "Moderateur"
},
{
"commands": ["!ann"],
"usage": "!ann <alias> <on/off/toggle>",
"description": "Activer/desactiver/inverser une liste d'annonce par alias",
"permission": "Moderateur"
},
{
"commands": ["!no_game"],
"usage": "!no_game <on/off>",
"description": "Desactiver/activer tous les jeux de la chaine",
"permission": "Moderateur"
},
{
"commands": ["!multitwitch"],
"usage": "!multitwitch [live1] [live2] ... | auto | reset",
"description": "Creation d'un lien MultiTwitch. '@' = chaine actuelle, 'auto' = depuis le titre, 'reset' = reinitialiser",
"permission": "Moderateur (creation) / Tous (affichage)"
},
{
"commands": ["!permit"],
"usage": "!permit <viewer> [minutes]",
"description": "Autorise temporairement un viewer a poster un lien (1 minute par defaut)",
"permission": "Moderateur"
},
]
TWITCH_PERMISSIONS = {'viewer': 'Tous (viewers)', 'sub': 'Abonnés', 'vip': 'VIP', 'moderator': 'Modérateur'}
@webapp.route("/twitch-moderation")
@require_page("twitch_moderation")
def twitch_moderation():
custom_commands = Commande.query.filter_by(twitch_enable=True).all()
logs = TwitchModerationLog.query.order_by(TwitchModerationLog.created_at.desc()).limit(50).all()
raw_channel = ConfigurationHelper().getValue("twitch_channel") or webapp.config["BOT_STATUS"].get("twitch_channel_name") or "chainesteve"
twitch_channel = (raw_channel or "").strip().lower() or "chainesteve"
embed_parent = request.host or "localhost"
# Link filter status
link_filter_config = TwitchLinkFilter.query.first()
link_filter_enabled = link_filter_config.enabled if link_filter_config else False
# Banned words
banned_words = TwitchBannedWord.query.filter_by(enabled=True).all()
# Live status (from BOT_STATUS)
bot_status = webapp.config.get("BOT_STATUS", {})
is_live = bot_status.get("twitch_is_live", False)
viewer_count = bot_status.get("twitch_viewer_count", 0)
stream_title = bot_status.get("twitch_stream_title", "")
game_name = bot_status.get("twitch_game_name", "")
started_at = bot_status.get("twitch_started_at")
stream_uptime = _format_stream_uptime(started_at) if is_live else None
return render_template(
"twitch-moderation.html",
commands=MODERATION_COMMANDS,
custom_commands=custom_commands,
logs=logs,
twitch_permissions=TWITCH_PERMISSIONS,
twitch_channel=twitch_channel,
embed_parent=embed_parent,
link_filter_enabled=link_filter_enabled,
banned_words=banned_words,
is_live=is_live,
viewer_count=viewer_count,
stream_title=stream_title,
game_name=game_name,
started_at=started_at,
stream_uptime=stream_uptime,
)
@webapp.route("/twitch-moderation/logs/clear")
@require_page("twitch_moderation")
def clear_twitch_logs():
if not can_write_page("twitch_moderation"):
return render_template("403.html"), 403
TwitchModerationLog.query.delete()
db.session.commit()
return redirect(url_for('twitch_moderation'))
@webapp.route("/twitch-moderation/add", methods=['POST'])
@require_page("twitch_moderation")
def add_twitch_commande():
if not can_write_page("twitch_moderation"):
return render_template("403.html"), 403
trigger = request.form.get('trigger')
response = request.form.get('response')
twitch_permission = request.form.get('twitch_permission') or 'viewer'
if twitch_permission not in TWITCH_PERMISSIONS:
twitch_permission = 'viewer'
if trigger and response:
if not trigger.startswith('!'):
trigger = '!' + trigger
existing = Commande.query.filter_by(trigger=trigger).first()
if not existing:
commande = Commande(trigger=trigger, response=response, discord_enable=False, twitch_enable=True, twitch_permission=twitch_permission)
db.session.add(commande)
db.session.commit()
return redirect(url_for('twitch_moderation'))
@webapp.route("/twitch-moderation/edit/<int:cmd_id>", methods=['POST'])
@require_page("twitch_moderation")
def edit_twitch_commande(cmd_id):
if not can_write_page("twitch_moderation"):
return jsonify({"success": False, "error": "Permission refusée"}), 403
data = request.get_json()
if not data:
return jsonify({"success": False, "error": "Données invalides"}), 400
commande = Commande.query.get_or_404(cmd_id)
trigger = (data.get('trigger') or '').strip()
response = (data.get('response') or '').strip()
twitch_permission = data.get('twitch_permission', commande.twitch_permission or 'viewer')
if not trigger or not response:
return jsonify({"success": False, "error": "Commande et réponse requises"}), 400
if not trigger.startswith('!'):
trigger = '!' + trigger
if twitch_permission not in TWITCH_PERMISSIONS:
twitch_permission = 'viewer'
duplicate = Commande.query.filter(Commande.trigger == trigger, Commande.id != cmd_id).first()
if duplicate:
return jsonify({"success": False, "error": f"La commande {trigger} existe déjà"}), 409
commande.trigger = trigger
commande.response = response
commande.twitch_permission = twitch_permission
db.session.commit()
return jsonify({
"success": True,
"command": {
"id": commande.id,
"trigger": commande.trigger,
"response": commande.response,
"twitch_permission": commande.twitch_permission,
"permission_label": TWITCH_PERMISSIONS.get(commande.twitch_permission, 'Tous'),
}
})
@webapp.route("/twitch-moderation/banned-word/add", methods=['POST'])
@require_page("twitch_moderation")
def add_banned_word():
if not can_write_page("twitch_moderation"):
return render_template("403.html"), 403
word = request.form.get('word', '').strip().lower()
timeout_duration = int(request.form.get('timeout_duration', 60))
if word:
existing = TwitchBannedWord.query.filter_by(word=word).first()
if not existing:
banned_word = TwitchBannedWord(word=word, enabled=True, timeout_duration=timeout_duration)
db.session.add(banned_word)
db.session.commit()
return redirect(url_for('twitch_moderation'))
@webapp.route("/twitch-moderation/banned-word/delete/<int:word_id>")
@require_page("twitch_moderation")
def delete_banned_word(word_id):
if not can_write_page("twitch_moderation"):
return render_template("403.html"), 403
banned_word = TwitchBannedWord.query.get_or_404(word_id)
db.session.delete(banned_word)
db.session.commit()
return redirect(url_for('twitch_moderation'))
@webapp.route("/twitch-moderation/send-message", methods=['POST'])
@require_page("twitch_moderation")
def send_twitch_message():
if not can_write_page("twitch_moderation"):
return jsonify({"success": False, "error": "Permission refusée"}), 403
data = request.get_json()
message = data.get('message', '').strip()
if not message:
return jsonify({"success": False, "error": "Message vide"}), 400
# Vérifier que le bot Twitch est connecté
from twitchbot import twitchBot
if not hasattr(twitchBot, 'chat') or not twitchBot.chat:
return jsonify({"success": False, "error": "Bot Twitch non connecté"}), 503
# Récupérer le nom du channel
channel = ConfigurationHelper().getValue('twitch_channel')
if not channel:
return jsonify({"success": False, "error": "Channel Twitch non configuré"}), 400
try:
if not twitchBot._loop:
return jsonify({"success": False, "error": "Event loop du bot non disponible"}), 503
async def send_msg():
await twitchBot.chat.send_message(channel, message)
future = asyncio.run_coroutine_threadsafe(send_msg(), twitchBot._loop)
future.result(timeout=10)
return jsonify({"success": True})
except TimeoutError:
return jsonify({"success": False, "error": "Timeout lors de l'envoi"}), 504
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@webapp.route("/twitch-moderation/messages")
@require_page("twitch_moderation")
def get_twitch_messages():
"""Retourne les derniers messages du chat Twitch"""
bot_status = webapp.config["BOT_STATUS"]
clear_chat = False
clear_reason = None
ended_at_raw = bot_status.get("twitch_ended_at")
if ended_at_raw:
try:
ended_at = datetime.fromisoformat(ended_at_raw)
if datetime.now(ended_at.tzinfo) >= ended_at + timedelta(hours=1):
if bot_status.get("twitch_chat_messages"):
bot_status["twitch_chat_messages"] = []
bot_status["twitch_msg_timestamps"] = []
bot_status["twitch_msg_per_minute"] = 0
clear_chat = True
clear_reason = "Chat vidé automatiquement 1h après la fin du live."
except ValueError:
pass
messages = list(bot_status.get("twitch_chat_messages", []))
return jsonify({
"messages": messages,
"msg_per_min": int(bot_status.get("twitch_msg_per_minute", 0)),
"clear_chat": clear_chat,
"clear_reason": clear_reason,
})
@webapp.route("/twitch-moderation/stream-info")
@require_page("twitch_moderation")
def twitch_stream_info():
"""Retourne les infos du stream en cours pour le polling dynamique."""
bot_status = webapp.config.get("BOT_STATUS", {})
return jsonify({
"is_live": bot_status.get("twitch_is_live", False),
"viewer_count": bot_status.get("twitch_viewer_count", 0),
"title": bot_status.get("twitch_stream_title", ""),
"game_name": bot_status.get("twitch_game_name", ""),
"started_at": bot_status.get("twitch_started_at"),
"msg_per_min": int(bot_status.get("twitch_msg_per_minute", 0)),
})
@webapp.route("/twitch-moderation/logs/poll")
@require_page("twitch_moderation")
def poll_twitch_logs():
"""Retourne les logs de modération plus récents qu'un timestamp donné."""
since_str = request.args.get('since', '')
since = None
if since_str:
try:
since = datetime.fromisoformat(since_str)
except ValueError:
pass
query = TwitchModerationLog.query.order_by(TwitchModerationLog.created_at.desc())
if since:
query = query.filter(TwitchModerationLog.created_at > since)
logs = query.limit(20).all()
now = datetime.now().isoformat()
return jsonify({
"logs": [
{
"id": log.id,
"action": log.action,
"moderator": log.moderator,
"target": log.target or '-',
"details": log.details or '-',
"created_at": log.created_at.strftime('%d/%m %H:%M') if log.created_at else '',
"created_at_iso": log.created_at.isoformat() if log.created_at else '',
}
for log in logs
],
"timestamp": now,
"total": TwitchModerationLog.query.count(),
})
@webapp.route("/twitch-moderation/execute-action", methods=['POST'])
@require_page("twitch_moderation")
def execute_moderation_action():
"""Exécute une action de modération directement"""
if not can_write_page("twitch_moderation"):
return jsonify({"success": False, "error": "Permission refusée"}), 403
data = request.get_json()
action = data.get('action', '').strip()
params = data.get('params', {})
if not action:
return jsonify({"success": False, "error": "Action non spécifiée"}), 400
# Vérifier que le bot Twitch est connecté
from twitchbot import twitchBot
if not hasattr(twitchBot, 'chat') or not twitchBot.chat or not hasattr(twitchBot, 'twitch'):
return jsonify({"success": False, "error": "Bot Twitch non connecté"}), 503
# Récupérer le nom du channel
channel = ConfigurationHelper().getValue('twitch_channel')
if not channel:
return jsonify({"success": False, "error": "Channel Twitch non configuré"}), 400
# Créer un objet ChatMessage simulé pour les commandes qui en ont besoin
from twitchAPI.chat import ChatMessage
from types import SimpleNamespace
admin_name = f"WebApp ({current_user.username})"
if not twitchBot._loop:
return jsonify({"success": False, "error": "Event loop du bot non disponible"}), 503
async def execute_action():
if action == 'timeout':
from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _get_user_id, _log_action
username = params.get('username', '').strip().lstrip('@')
duration = int(params.get('duration', 600))
reason = params.get('reason', 'Timeout')
broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel)
moderator_id = await _get_moderator_id(twitchBot.twitch)
user_id = await _get_user_id(twitchBot.twitch, username)
if user_id:
await twitchBot.twitch.ban_user(broadcaster_id, moderator_id, user_id, reason=reason, duration=duration)
_log_action("timeout", admin_name, username, f"{duration}s - {reason}")
return {"success": True, "message": f"Timeout de {username} pour {duration}s"}
return {"success": False, "error": f"Utilisateur {username} introuvable"}
elif action == 'ban':
from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _get_user_id, _log_action
username = params.get('username', '').strip().lstrip('@')
reason = params.get('reason', 'Ban')
broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel)
moderator_id = await _get_moderator_id(twitchBot.twitch)
user_id = await _get_user_id(twitchBot.twitch, username)
if user_id:
await twitchBot.twitch.ban_user(broadcaster_id, moderator_id, user_id, reason=reason)
_log_action("ban", admin_name, username, reason)
return {"success": True, "message": f"Ban de {username}"}
return {"success": False, "error": f"Utilisateur {username} introuvable"}
elif action == 'clean':
from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _get_user_id, _log_action
username = params.get('username', '').strip().lstrip('@')
broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel)
moderator_id = await _get_moderator_id(twitchBot.twitch)
if username:
user_id = await _get_user_id(twitchBot.twitch, username)
if user_id:
await twitchBot.twitch.ban_user(broadcaster_id, moderator_id, user_id, reason="Purge messages", duration=1)
_log_action("clean", admin_name, username)
return {"success": True, "message": f"Messages de {username} supprimés"}
return {"success": False, "error": f"Utilisateur {username} introuvable"}
else:
await twitchBot.twitch.delete_chat_message(broadcaster_id, moderator_id)
_log_action("clean", admin_name, None, "Chat complet")
return {"success": True, "message": "Chat nettoyé"}
elif action == 'permit':
from database.models import TwitchPermit
username = params.get('username', '').strip().lstrip('@').lower()
duration = int(params.get('duration', 60))
expires_at = datetime.now() + timedelta(seconds=duration)
with webapp.app_context():
existing = TwitchPermit.query.filter_by(username=username).first()
if existing:
existing.expires_at = expires_at
else:
permit = TwitchPermit(username=username, expires_at=expires_at)
db.session.add(permit)
db.session.commit()
return {"success": True, "message": f"Permit accordé à {username} pour {duration//60}min"}
elif action in ['subon', 'suboff', 'emoteon', 'emoteoff']:
from twitchbot.moderation import _get_broadcaster_id, _get_moderator_id, _log_action
broadcaster_id = await _get_broadcaster_id(twitchBot.twitch, channel)
moderator_id = await _get_moderator_id(twitchBot.twitch)
if action == 'subon':
await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, subscriber_mode=True)
_log_action("subon", admin_name)
return {"success": True, "message": "Mode abonnés activé"}
elif action == 'suboff':
await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, subscriber_mode=False)
_log_action("suboff", admin_name)
return {"success": True, "message": "Mode abonnés désactivé"}
elif action == 'emoteon':
await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, emote_mode=True)
_log_action("emoteon", admin_name)
return {"success": True, "message": "Mode emote activé"}
elif action == 'emoteoff':
await twitchBot.twitch.update_chat_settings(broadcaster_id, moderator_id, emote_mode=False)
_log_action("emoteoff", admin_name)
return {"success": True, "message": "Mode emote désactivé"}
return {"success": False, "error": f"Action '{action}' non reconnue"}
try:
future = asyncio.run_coroutine_threadsafe(execute_action(), twitchBot._loop)
result = future.result(timeout=15)
return jsonify(result)
except TimeoutError:
return jsonify({"success": False, "error": "Timeout lors de l'exécution"}), 504
except Exception as e:
import logging
logging.error(f"Erreur lors de l'exécution de l'action {action}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
# =============================
# Shoutbox modérateurs
# =============================
@webapp.route("/twitch-moderation/shoutbox/send", methods=['POST'])
@require_page("twitch_moderation")
def shoutbox_send():
if not can_write_page("twitch_moderation"):
return jsonify({"success": False, "error": "Permission refusée"}), 403
data = request.get_json()
message = (data.get('message') or '').strip()[:500]
if not message:
return jsonify({"success": False, "error": "Message vide"}), 400
msg = ModShoutboxMessage(
author=current_user.username,
message=message,
created_at=datetime.now(),
)
db.session.add(msg)
db.session.commit()
return jsonify({"success": True, "id": msg.id})
@webapp.route("/twitch-moderation/shoutbox/transfer", methods=['POST'])
@require_page("twitch_moderation")
def shoutbox_transfer():
if not can_write_page("twitch_moderation"):
return jsonify({"success": False, "error": "Permission refusée"}), 403
data = request.get_json() or {}
username = (data.get('username') or '').strip().lstrip('@')
message = (data.get('message') or '').strip()
if not username or not message:
return jsonify({"success": False, "error": "Données incomplètes"}), 400
text = f"@{username}: {message}"
msg = ModShoutboxMessage(
author=current_user.username,
message=text[:500],
created_at=datetime.now(),
)
db.session.add(msg)
db.session.commit()
return jsonify({"success": True, "id": msg.id})
@webapp.route("/twitch-moderation/shoutbox/messages")
@require_page("twitch_moderation")
def shoutbox_messages():
since_str = request.args.get('since', '')
since = None
if since_str:
try:
since = datetime.fromisoformat(since_str)
except ValueError:
pass
chat_query = ModShoutboxMessage.query
log_query = TwitchModerationLog.query
if since:
chat_query = chat_query.filter(ModShoutboxMessage.created_at > since)
log_query = log_query.filter(TwitchModerationLog.created_at > since)
chat_msgs = chat_query.order_by(ModShoutboxMessage.created_at.desc()).limit(100).all()
log_msgs = log_query.order_by(TwitchModerationLog.created_at.desc()).limit(100).all()
items = []
for m in chat_msgs:
items.append({
"type": "message",
"id": f"msg-{m.id}",
"author": m.author,
"text": m.message,
"created_at": m.created_at.isoformat() if m.created_at else '',
})
for log in log_msgs:
items.append({
"type": "sanction",
"id": f"log-{log.id}",
"action": log.action,
"moderator": log.moderator,
"target": log.target or '',
"details": log.details or '',
"created_at": log.created_at.isoformat() if log.created_at else '',
})
items.sort(key=lambda x: x["created_at"])
items = items[-100:]
return jsonify({
"items": items,
"timestamp": datetime.now().isoformat(),
"online_users": _get_online_users(),
})
def _get_online_users():
heartbeats = webapp.config["BOT_STATUS"].get("shoutbox_heartbeats", {})
cutoff = datetime.now() - timedelta(seconds=15)
return sorted(u for u, t in heartbeats.items() if t > cutoff)
@webapp.route("/twitch-moderation/shoutbox/heartbeat", methods=['POST'])
@require_page("twitch_moderation")
def shoutbox_heartbeat():
hb = webapp.config["BOT_STATUS"].setdefault("shoutbox_heartbeats", {})
hb[current_user.username] = datetime.now()
return jsonify({"online_users": _get_online_users()})
@webapp.route("/twitch-moderation/shoutbox/clear")
@require_page("twitch_moderation")
def shoutbox_clear():
if not can_write_page("twitch_moderation"):
return jsonify({"success": False, "error": "Permission refusée"}), 403
ModShoutboxMessage.query.delete()
db.session.commit()
return jsonify({"success": True})
@webapp.route("/twitch-moderation/shoutbox/popout")
@require_page("twitch_moderation")
def shoutbox_popout():
return render_template("shoutbox-popout.html")
+119
View File
@@ -0,0 +1,119 @@
# Gestion des utilisateurs webapp (réservé super administrateur).
from flask import render_template, request, redirect, url_for, flash
from werkzeug.security import generate_password_hash
from webapp import webapp
from webapp.auth import require_page
from database import db
from database.models import WebappUser, WebappRole
ROLE_LABELS = {
"viewer_twitch": "Viewer Twitch",
"utilisateur_discord": "Utilisateur Discord",
"moderateur_discord": "Modérateur Discord",
"expert_discord": "Expert Discord",
"moderateur_twitch": "Modérateur Twitch",
"super_administrateur": "Super administrateur",
}
def _role_labels():
roles = WebappRole.query.order_by(WebappRole.level).all()
return {r.name: r.name.replace("_", " ").title() for r in roles}
@webapp.route("/users")
@require_page("users")
def users_list():
users = WebappUser.query.order_by(WebappUser.created_at.desc()).all()
roles = WebappRole.query.order_by(WebappRole.level).all()
labels = dict(ROLE_LABELS)
labels.update(_role_labels())
return render_template(
"users.html",
users=users,
roles=[r.name for r in roles],
role_labels=labels,
)
@webapp.route("/users/role/<int:user_id>", methods=["POST"])
@require_page("users")
def users_set_role(user_id):
user = WebappUser.query.get_or_404(user_id)
new_role = request.form.get("role")
existing = WebappRole.query.filter_by(name=new_role).first()
if new_role and existing:
user.role = new_role
db.session.commit()
return redirect(url_for("users_list"))
@webapp.route("/users/create", methods=["POST"])
@require_page("users")
def users_create():
"""Création d'un utilisateur par un administrateur."""
username = (request.form.get("username") or "").strip()
email = (request.form.get("email") or "").strip().lower()
password = request.form.get("password") or ""
password_confirm = request.form.get("password_confirm") or ""
role = request.form.get("role") or "viewer_twitch"
errors = []
# Validations
if len(username) < 3:
errors.append("Le nom d'utilisateur doit faire au moins 3 caractères.")
if len(email) < 5 or "@" not in email:
errors.append("Adresse e-mail invalide.")
if len(password) < 8:
errors.append("Le mot de passe doit faire au moins 8 caractères.")
if password != password_confirm:
errors.append("Les mots de passe ne correspondent pas.")
if WebappUser.query.filter_by(username=username).first():
errors.append("Ce nom d'utilisateur est déjà pris.")
if WebappUser.query.filter_by(email=email).first():
errors.append("Cette adresse e-mail est déjà utilisée.")
# Vérifier que le rôle existe
if not WebappRole.query.filter_by(name=role).first():
errors.append("Rôle invalide.")
if errors:
for msg in errors:
flash(msg, "error")
return redirect(url_for("users_list"))
# Créer l'utilisateur
user = WebappUser(
username=username,
email=email,
password_hash=generate_password_hash(password, method="scrypt"),
role=role,
)
db.session.add(user)
db.session.commit()
flash(f"Utilisateur « {username} » créé avec succès.", "success")
return redirect(url_for("users_list"))
@webapp.route("/users/delete/<int:user_id>", methods=["POST"])
@require_page("users")
def users_delete(user_id):
"""Suppression d'un utilisateur (sauf soi-même)."""
from flask_login import current_user
user = WebappUser.query.get_or_404(user_id)
# Protection : impossible de se supprimer soi-même
if user.id == current_user.id:
flash("Vous ne pouvez pas supprimer votre propre compte.", "error")
return redirect(url_for("users_list"))
username = user.username
db.session.delete(user)
db.session.commit()
flash(f"Utilisateur « {username} » supprimé.", "success")
return redirect(url_for("users_list"))
+253
View File
@@ -0,0 +1,253 @@
import re
import requests
from urllib.parse import urlencode
from flask import render_template, request, redirect, url_for
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
from database.models import YouTubeNotification, YouTubeVideoHistory
from discordbot import bot
def extract_channel_id(channel_input: str) -> str:
"""Extrait l'ID de la chaîne YouTube depuis différents formats"""
if not channel_input:
return None
channel_input = channel_input.strip()
if channel_input.startswith('UC') and len(channel_input) == 24:
return channel_input
if '/channel/' in channel_input:
match = re.search(r'/channel/([a-zA-Z0-9_-]{24})', channel_input)
if match:
return match.group(1)
if '/c/' in channel_input or '/user/' in channel_input:
parts = channel_input.split('/')
for i, part in enumerate(parts):
if part in ['c', 'user'] and i + 1 < len(parts):
handle = parts[i + 1].split('?')[0].split('&')[0]
channel_id = _get_channel_id_from_handle(handle)
if channel_id:
return channel_id
if '@' in channel_input:
handle = re.search(r'@([a-zA-Z0-9_-]+)', channel_input)
if handle:
channel_id = _get_channel_id_from_handle(handle.group(1))
if channel_id:
return channel_id
return None
def _get_channel_id_from_handle(handle: str) -> str:
"""Récupère l'ID de la chaîne depuis un handle en utilisant le flux RSS"""
try:
url = f"https://www.youtube.com/@{handle}"
response = requests.get(url, timeout=10, allow_redirects=True)
if response.status_code == 200:
channel_id_match = re.search(r'"channelId":"([^"]{24})"', response.text)
if channel_id_match:
return channel_id_match.group(1)
canonical_match = re.search(r'<link rel="canonical" href="https://www\.youtube\.com/channel/([^"]{24})"', response.text)
if canonical_match:
return canonical_match.group(1)
return None
except Exception:
return None
@webapp.route("/youtube")
@require_page("youtube")
def openYouTube():
notifications: list[YouTubeNotification] = YouTubeNotification.query.all()
channels = bot.getAllTextChannel()
for notification in notifications:
for channel in channels:
if notification.notify_channel == channel.id:
notification.notify_channel_name = channel.name
msg = request.args.get('msg')
msg_type = request.args.get('type', 'info')
return render_template("youtube.html", notifications=notifications, channels=channels, msg=msg, msg_type=msg_type)
@webapp.route("/youtube/add", methods=['POST'])
@require_page("youtube")
def addYouTube():
if not can_write_page("youtube"):
return render_template("403.html"), 403
channel_input = request.form.get('channel_id', '').strip()
channel_id = extract_channel_id(channel_input)
if not channel_id:
return redirect(url_for("openYouTube") + "?" + urlencode({'msg': f"Impossible d'extraire l'ID de la chaîne depuis : {channel_input}. Veuillez vérifier le lien.", 'type': 'error'}))
notify_channel_str = request.form.get('notify_channel')
if not notify_channel_str:
return redirect(url_for("openYouTube") + "?" + urlencode({'msg': "Veuillez sélectionner un canal Discord. Assurez-vous que le bot Discord est connecté.", 'type': 'error'}))
try:
notify_channel = int(notify_channel_str)
except ValueError:
return redirect(url_for("openYouTube") + "?" + urlencode({'msg': "Canal Discord invalide.", 'type': 'error'}))
embed_color = request.form.get('embed_color', 'FF0000').strip().lstrip('#')
if len(embed_color) != 6:
embed_color = 'FF0000'
notification = YouTubeNotification(
enable=True,
channel_id=channel_id,
notify_channel=notify_channel,
message=request.form.get('message'),
video_type=request.form.get('video_type', 'all'),
embed_title=request.form.get('embed_title') or None,
embed_description=request.form.get('embed_description') or None,
embed_color=embed_color,
embed_footer=request.form.get('embed_footer') or None,
embed_author_name=request.form.get('embed_author_name') or None,
embed_author_icon=(request.form.get('embed_author_icon') or '').strip() or None,
embed_thumbnail=request.form.get('embed_thumbnail') == 'on',
embed_image=request.form.get('embed_image') == 'on'
)
db.session.add(notification)
db.session.commit()
return redirect(url_for("openYouTube") + "?" + urlencode({'msg': f"Notification ajoutée avec succès pour la chaîne {channel_id}", 'type': 'success'}))
@webapp.route("/youtube/toggle/<int:id>")
@require_page("youtube")
def toggleYouTube(id):
if not can_write_page("youtube"):
return render_template("403.html"), 403
notification: YouTubeNotification = YouTubeNotification.query.get_or_404(id)
notification.enable = not notification.enable
db.session.commit()
return redirect(url_for("openYouTube"))
@webapp.route("/youtube/edit/<int:id>")
@require_page("youtube")
def openEditYouTube(id):
notification = YouTubeNotification.query.get_or_404(id)
channels = bot.getAllTextChannel()
msg = request.args.get('msg')
msg_type = request.args.get('type', 'info')
return render_template("youtube.html", notification=notification, channels=channels, notifications=YouTubeNotification.query.all(), msg=msg, msg_type=msg_type)
@webapp.route("/youtube/edit/<int:id>", methods=['POST'])
@require_page("youtube")
def submitEditYouTube(id):
if not can_write_page("youtube"):
return render_template("403.html"), 403
notification: YouTubeNotification = YouTubeNotification.query.get_or_404(id)
channel_input = request.form.get('channel_id', '').strip()
channel_id = extract_channel_id(channel_input)
if not channel_id:
return redirect(url_for("openEditYouTube", id=id) + "?" + urlencode({'msg': f"Impossible d'extraire l'ID de la chaîne depuis : {channel_input}. Veuillez vérifier le lien.", 'type': 'error'}))
notify_channel_str = request.form.get('notify_channel')
if not notify_channel_str:
return redirect(url_for("openEditYouTube", id=id) + "?" + urlencode({'msg': "Veuillez sélectionner un canal Discord. Assurez-vous que le bot Discord est connecté.", 'type': 'error'}))
try:
notify_channel = int(notify_channel_str)
except ValueError:
return redirect(url_for("openEditYouTube", id=id) + "?" + urlencode({'msg': "Canal Discord invalide.", 'type': 'error'}))
embed_color = request.form.get('embed_color', 'FF0000').strip().lstrip('#')
if len(embed_color) != 6:
embed_color = 'FF0000'
notification.channel_id = channel_id
notification.notify_channel = notify_channel
notification.message = request.form.get('message')
notification.video_type = request.form.get('video_type', 'all')
notification.embed_title = request.form.get('embed_title') or None
notification.embed_description = request.form.get('embed_description') or None
notification.embed_color = embed_color
notification.embed_footer = request.form.get('embed_footer') or None
notification.embed_author_name = request.form.get('embed_author_name') or None
notification.embed_author_icon = (request.form.get('embed_author_icon') or '').strip() or None
notification.embed_thumbnail = request.form.get('embed_thumbnail') == 'on'
notification.embed_image = request.form.get('embed_image') == 'on'
db.session.commit()
return redirect(url_for("openYouTube") + "?" + urlencode({'msg': "Notification modifiée avec succès", 'type': 'success'}))
@webapp.route("/youtube/del/<int:id>")
@require_page("youtube")
def delYouTube(id):
if not can_write_page("youtube"):
return render_template("403.html"), 403
notification = YouTubeNotification.query.get_or_404(id)
YouTubeVideoHistory.query.filter_by(notification_id=id).delete()
db.session.delete(notification)
db.session.commit()
return redirect(url_for("openYouTube"))
@webapp.route("/youtube/history")
@require_page("youtube")
def youtubeHistory():
page = request.args.get('page', 1, type=int)
per_page = 20
history_filter = request.args.get('filter', 'all')
if history_filter not in {'all', 'video', 'short'}:
history_filter = 'all'
history_query = YouTubeVideoHistory.query
if history_filter == 'video':
history_query = history_query.filter(YouTubeVideoHistory.is_short.is_(False))
elif history_filter == 'short':
history_query = history_query.filter(YouTubeVideoHistory.is_short.is_(True))
# published_at provient du flux YouTube (format ISO 8601), ce qui permet de
# présenter les vidéos par date de publication, plutôt que par date de détection.
history_query = history_query.order_by(
YouTubeVideoHistory.published_at.desc(),
YouTubeVideoHistory.detected_at.desc(),
)
total = history_query.count()
history = history_query.offset((page - 1) * per_page).limit(per_page).all()
total_pages = (total + per_page - 1) // per_page
notification_map = {}
for entry in history:
if entry.notification_id not in notification_map:
notif = YouTubeNotification.query.get(entry.notification_id)
notification_map[entry.notification_id] = notif
msg = request.args.get('msg')
msg_type = request.args.get('type', 'info')
return render_template(
"youtube-history.html",
history=history,
notification_map=notification_map,
page=page,
total_pages=total_pages,
total=total,
history_filter=history_filter,
msg=msg,
msg_type=msg_type,
)
@webapp.route("/youtube/notify/<int:history_id>", methods=['POST'])
@require_page("youtube")
def forceYouTubeNotify(history_id):
if not can_write_page("youtube"):
return render_template("403.html"), 403
from discordbot.youtube import send_video_notification_sync
success, message = send_video_notification_sync(history_id)
msg_type = 'success' if success else 'error'
return redirect(url_for("youtubeHistory") + "?" + urlencode({'msg': message, 'type': msg_type}))