From 0cec2e36dde8662874a6dbe17fa6545205b17d02 Mon Sep 17 00:00:00 2001 From: Andro2K Date: Tue, 11 Aug 2026 18:42:02 -0500 Subject: [PATCH 1/5] feat: implement spam filtering system with dedicated storage, services, and controllers --- backend/controllers/chat_controller.py | 33 ++++- backend/database/manager.py | 6 +- backend/database/spam_storage.py | 15 ++- backend/providers/voices/tts_local.py | 40 +++++-- backend/services/auth/oauth_service.py | 42 +++++-- backend/services/chat/command_service.py | 18 +-- backend/services/chat/pipeline.py | 1 + backend/services/chat/spam_service.py | 126 +++++++++++++------- backend/workers/twitch_auth_worker.py | 5 +- frontend/core/main_window_core.py | 27 ++++- frontend/widgets/blocks.py | 37 ++++++ locales/en.json | 11 +- locales/es.json | 11 +- tests/test_command_parser.py | 6 + tests/test_spam_service.py | 42 +++++++ tests/test_tts_local.py | 12 ++ tests/test_twitch_auth.py | 45 +++++++ walkthroughs/v1.5.0/Release_Notes_v1.5.0.md | 33 ++++- walkthroughs/v1.5.0/WT-1.5.0_02.md | 119 ++++++++++++++++++ 19 files changed, 534 insertions(+), 95 deletions(-) create mode 100644 tests/test_tts_local.py create mode 100644 tests/test_twitch_auth.py create mode 100644 walkthroughs/v1.5.0/WT-1.5.0_02.md diff --git a/backend/controllers/chat_controller.py b/backend/controllers/chat_controller.py index bb7cd80..eda7fe5 100644 --- a/backend/controllers/chat_controller.py +++ b/backend/controllers/chat_controller.py @@ -1,5 +1,6 @@ # backend\controllers\chat_controller.py +from collections import deque import logging from PySide6.QtCore import QObject, Slot, Signal from backend.handlers import TTSVoiceHandler, ChatFilterHandler @@ -24,6 +25,7 @@ def __init__(self, view, service, command_service, spam_service, i18n, timer_ser self.i18n = i18n self.timer_service = timer_service self.toast = toast_manager + self._message_buffer = deque(maxlen=200) self.filter_handler = ChatFilterHandler(i18n, service) self.voice_handler = TTSVoiceHandler(self, view, service, toast_manager, i18n) @@ -40,17 +42,22 @@ def __init__(self, view, service, command_service, spam_service, i18n, timer_ser self.pipeline = MessagePipeline() self._build_pipeline() + self.command_service.response_generated.connect(self._handle_bot_response) if self.view is not None: self._connect_signals() self._load_initial_data() def attach_view(self, view) -> None: + first_attach = (self.view is None) self.view = view if self.voice_handler: self.voice_handler.view = view if self.view is not None: self._connect_signals() self._load_initial_data() + if first_attach and self._message_buffer: + for item in self._message_buffer: + self.view.append_message(item["user"], item["content"], item["color"], timestamp=item["timestamp"], role=item["role"], platform=item["platform"]) @property def muted_bots(self) -> set[str]: @@ -62,8 +69,8 @@ def banned_words(self) -> set[str]: def _build_pipeline(self) -> None: self.pipeline.register(self._step_spam) - self.pipeline.register(self._step_commands) self.pipeline.register(self._step_ui_render) + self.pipeline.register(self._step_commands) self.pipeline.register(self._step_tts) def _connect_signals(self) -> None: @@ -184,7 +191,8 @@ def process_message(self, dto: ChatMessageDTO) -> None: def _step_spam(self, dto: ChatMessageDTO) -> None: emotes_tag = getattr(dto, "emotes_tag", "") - if self.spam_service.is_spam(dto.user, dto.content, dto.badges, dto.msg_id, dto.sender_id, emotes_tag=emotes_tag): + platform = getattr(dto, "platform", "kick") + if self.spam_service.is_spam(dto.user, dto.content, dto.badges, dto.msg_id, dto.sender_id, emotes_tag=emotes_tag, platform=platform): dto.is_cancelled = True self.spam_blocked.emit() @@ -275,11 +283,32 @@ def _step_ui_render(self, dto: ChatMessageDTO) -> None: badges.append("bot") role_name = self._resolve_user_role(badges, dto.user) platform = getattr(dto, "platform", "kick") + item = { + "user": dto.user, "content": dto.content, "color": dto.color, "timestamp": dto.timestamp, + "role": role_name, "platform": platform + } + self._message_buffer.append(item) if self.view is not None: self.view.append_message(dto.user, dto.content, dto.color, timestamp=dto.timestamp, role=role_name, platform=platform) emotes_tag = getattr(dto, "emotes_tag", "") self.message_received.emit(dto.user, dto.content, dto.color, badges, platform, emotes_tag) + def _handle_bot_response(self, text: str, platform: str = "kick") -> None: + if not text or platform != "twitch": + return + import datetime + now_str = datetime.datetime.now().strftime("%H:%M:%S") + bot_user = "MiniKick" + if hasattr(self.command_service, "twitch_worker") and self.command_service.twitch_worker: + tw_worker = self.command_service.twitch_worker + bot_user = getattr(tw_worker, "bot_nick", "") or getattr(tw_worker, "channel_name", "") or "MiniKick" + + dto = ChatMessageDTO( + user=bot_user, content=text, badges=["broadcaster", "bot"], color="#9146FF", + msg_id="", sender_id=0, timestamp=now_str, platform="twitch", is_cancelled=False, is_command=False + ) + self._step_ui_render(dto) + def _step_tts(self, dto: ChatMessageDTO) -> None: if getattr(dto, "is_command", False): return diff --git a/backend/database/manager.py b/backend/database/manager.py index 9f1a79d..2bbad6f 100644 --- a/backend/database/manager.py +++ b/backend/database/manager.py @@ -123,7 +123,9 @@ def _create_tables(self) -> None: duration INTEGER DEFAULT 5, exclude_group TEXT DEFAULT 'none', max_amount INTEGER DEFAULT 0, - allowlist TEXT DEFAULT '' + allowlist TEXT DEFAULT '', + apply_kick INTEGER DEFAULT 1, + apply_twitch INTEGER DEFAULT 1 ) """) cursor.execute(""" @@ -347,6 +349,8 @@ def _create_tables(self) -> None: def _upgrade_schema(self) -> None: expected_columns = { "spam_filters": [ + ("apply_kick", "INTEGER DEFAULT 1"), + ("apply_twitch", "INTEGER DEFAULT 1"), ("allowlist", "TEXT DEFAULT ''"), ("max_amount", "INTEGER DEFAULT 0"), ("exclude_group", "TEXT DEFAULT 'none'"), diff --git a/backend/database/spam_storage.py b/backend/database/spam_storage.py index 4798fb5..ae7d8b6 100644 --- a/backend/database/spam_storage.py +++ b/backend/database/spam_storage.py @@ -9,7 +9,7 @@ def __init__(self, db_manager: DatabaseManager): def load_all(self) -> dict: with self.db_manager.get_connection() as conn: cursor = conn.cursor() - cursor.execute("SELECT filter_id, is_active, penalty, duration, exclude_group, max_amount, allowlist FROM spam_filters") + cursor.execute("SELECT filter_id, is_active, penalty, duration, exclude_group, max_amount, allowlist, apply_kick, apply_twitch FROM spam_filters") filters = {} for row in cursor.fetchall(): filters[row[0]] = { @@ -18,7 +18,9 @@ def load_all(self) -> dict: "duration": row[3], "exclude_group": row[4], "max_amount": row[5], - "allowlist": row[6] if len(row) > 6 and row[6] is not None else "" + "allowlist": row[6] if len(row) > 6 and row[6] is not None else "", + "apply_kick": bool(row[7]) if len(row) > 7 and row[7] is not None else True, + "apply_twitch": bool(row[8]) if len(row) > 8 and row[8] is not None else True } return filters @@ -26,14 +28,15 @@ def save_filter(self, filter_id: str, config: dict) -> None: with self.db_manager.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" - INSERT INTO spam_filters (filter_id, is_active, penalty, duration, exclude_group, max_amount, allowlist) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO spam_filters (filter_id, is_active, penalty, duration, exclude_group, max_amount, allowlist, apply_kick, apply_twitch) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(filter_id) DO UPDATE SET is_active=excluded.is_active, penalty=excluded.penalty, duration=excluded.duration, - exclude_group=excluded.exclude_group, max_amount=excluded.max_amount, allowlist=excluded.allowlist + exclude_group=excluded.exclude_group, max_amount=excluded.max_amount, allowlist=excluded.allowlist, + apply_kick=excluded.apply_kick, apply_twitch=excluded.apply_twitch """, ( filter_id, int(config.get("is_active", False)), config.get("penalty", "timeout"), config.get("duration", 300), config.get("exclude_group", "none"), config.get("max_amount", 0), - config.get("allowlist", "") + config.get("allowlist", ""), int(config.get("apply_kick", True)), int(config.get("apply_twitch", True)) )) conn.commit() diff --git a/backend/providers/voices/tts_local.py b/backend/providers/voices/tts_local.py index c4ff64f..43c4d28 100644 --- a/backend/providers/voices/tts_local.py +++ b/backend/providers/voices/tts_local.py @@ -20,24 +20,42 @@ def prepare(self, text: str, voice_id: str = None) -> None: pass def speak(self, text: str, voice_id: str = None) -> None: + if not text: + return target_voice = voice_id if voice_id else self.voice_id with self._lock: + engine = None try: - if not self._engine: - import pythoncom - pythoncom.CoInitialize() - self._engine = pyttsx3.init() - - self._engine.setProperty("rate", self.rate) - self._engine.setProperty("volume", self.volume) + import pythoncom + pythoncom.CoInitialize() + engine = pyttsx3.init() + engine.setProperty("rate", self.rate) + engine.setProperty("volume", self.volume) if target_voice: - self._engine.setProperty("voice", target_voice) + try: + engine.setProperty("voice", target_voice) + except Exception as ve: + logging.warning("[Local TTS] Could not set voice %s: %s", target_voice, ve) - self._engine.say(text) - self._engine.runAndWait() - + engine.say(text) + engine.runAndWait() except Exception as e: logging.error("[Local TTS] Speech error: %s", e) + finally: + if engine is not None: + try: + engine.stop() + except Exception: + pass + try: + del engine + except Exception: + pass + try: + import pythoncom + pythoncom.CoUninitialize() + except Exception: + pass def stop(self) -> None: pass diff --git a/backend/services/auth/oauth_service.py b/backend/services/auth/oauth_service.py index 411c609..a609506 100644 --- a/backend/services/auth/oauth_service.py +++ b/backend/services/auth/oauth_service.py @@ -197,20 +197,23 @@ def __init__(self, client_id: str, client_secret: str, redirect_uri: str, storag self.storage = storage self.success_html_path = success_html_path - def get_tokens(self) -> dict: - tokens = self.storage.load() - if tokens and "access_token" in tokens: - return tokens - return self._new_login() + def get_tokens(self, force: bool = False) -> dict: + if not force: + tokens = self.storage.load() + if tokens and "access_token" in tokens and not self.has_missing_scopes(): + return tokens + return self._new_login(force=force) - def _new_login(self) -> dict: - scopes = "chat:read chat:edit user:read:chat channel:moderate" + def _new_login(self, force: bool = False) -> dict: + scopes = "chat:read chat:edit user:read:chat user:write:chat channel:moderate moderator:manage:chat_messages moderator:manage:banned_users" + force_param = "&force_verify=true" if force else "" auth_url = ( f"{TWITCH_AUTH_URL}?response_type=code" f"&client_id={self.client_id}" f"&redirect_uri={self.redirect_uri}" f"&scope={scopes}" f"&state=random" + f"{force_param}" ) port = int(urlparse(self.redirect_uri).port or 8080) auth_code = OAuthCallbackServer.capture_auth_code(auth_url, port, self.success_html_path, provider="twitch") @@ -247,3 +250,28 @@ def _exchange_code(self, code: str) -> dict: def logout(self) -> None: self.storage.clear() + + def get_missing_scopes(self) -> list[str]: + tokens = self.storage.load() + if not tokens: + return [] + + REQUIRED_TWITCH_SCOPES = { + "moderator:manage:chat_messages": "dashboard.banner.scope.twitch_moderation_chat", + "moderator:manage:banned_users": "dashboard.banner.scope.twitch_moderation_ban", + } + + raw_scopes = tokens.get("scope", "") + if isinstance(raw_scopes, list): + scopes_set = set(raw_scopes) + else: + scopes_set = set(raw_scopes.split()) + + return [ + i18n_key + for scope, i18n_key in REQUIRED_TWITCH_SCOPES.items() + if scope not in scopes_set + ] + + def has_missing_scopes(self) -> bool: + return len(self.get_missing_scopes()) > 0 diff --git a/backend/services/chat/command_service.py b/backend/services/chat/command_service.py index 2826eaf..da4f830 100644 --- a/backend/services/chat/command_service.py +++ b/backend/services/chat/command_service.py @@ -8,6 +8,7 @@ class CommandService(QObject): commands_changed = Signal() + response_generated = Signal(str, str) _PERMISSIONS = { "everyone": 0, "subscriber": 1, "vip": 2, "moderator": 3, "broadcaster": 4 @@ -202,18 +203,21 @@ def _try_execute(self, cmd: dict, user: str, touser: str, badges: list, matched_ return True, "", cmd, matched_prefix def send_response(self, response_text: str, platform: str = "kick"): + if not response_text: + return + if platform == "twitch": tw_worker = getattr(self, "twitch_worker", None) if tw_worker and hasattr(tw_worker, "send_bot_message"): try: tw_worker.send_bot_message(response_text) - return except Exception as e: logging.error("[CommandService] Error enviando mensaje a Twitch: %s", e) + else: + if self.api_client: + try: + self.api_client.post_chat_message(content=response_text, msg_type="bot") + except Exception as e: + logging.error("[CommandService] Error enviando respuesta a Kick: %s", e) - if not self.api_client: - return - try: - self.api_client.post_chat_message(content=response_text, msg_type="bot") - except Exception as e: - logging.error("[CommandService] Error enviando respuesta a Kick: %s", e) + self.response_generated.emit(response_text, platform) diff --git a/backend/services/chat/pipeline.py b/backend/services/chat/pipeline.py index 78ce10f..cf2bce4 100644 --- a/backend/services/chat/pipeline.py +++ b/backend/services/chat/pipeline.py @@ -15,6 +15,7 @@ class ChatMessageDTO: platform: str = "kick" is_cancelled: bool = False emotes_tag: str = "" + is_command: bool = False class MessagePipeline: def __init__(self): diff --git a/backend/services/chat/spam_service.py b/backend/services/chat/spam_service.py index 4515d7a..820c17b 100644 --- a/backend/services/chat/spam_service.py +++ b/backend/services/chat/spam_service.py @@ -11,6 +11,9 @@ class SpamService: def __init__(self, storage, api_client=None, max_history_size: int = 1000, i18n=None): self.storage = storage self.api_client = api_client + self.twitch_api = None + self.twitch_worker = None + self.twitch_broadcaster_id = "" self.i18n = i18n self.broadcaster_id = 0 self.filters = {} @@ -26,7 +29,16 @@ def save_filter(self, filter_id: str, config: dict): self.storage.save_filter(filter_id, config) self.reload_filters() - def is_spam(self, user: str, message: str, badges: list, msg_id: str, sender_id: int, emotes_tag: str = "") -> bool: + def _get_clean_text(self, message: str, emotes_tag: str = "", strip_urls: bool = False) -> str: + clean_msg = self._KICK_EMOTE_REGEX.sub('', message) + if emotes_tag: + from backend.providers.chat.twitch_websocket import TwitchSocketManager + clean_msg = TwitchSocketManager.strip_twitch_emotes(clean_msg, emotes_tag) + if strip_urls: + clean_msg = self._LINK_REGEX.sub('', clean_msg) + return clean_msg + + def is_spam(self, user: str, message: str, badges: list, msg_id: str, sender_id: int, emotes_tag: str = "", platform: str = "kick") -> bool: if not message: return False @@ -42,6 +54,11 @@ def is_spam(self, user: str, message: str, badges: list, msg_id: str, sender_id: for f_id, config in self.filters.items(): if not config.get("is_active"): continue + if platform == "kick" and not config.get("apply_kick", True): + continue + if platform == "twitch" and not config.get("apply_twitch", True): + continue + exclude_group = config.get("exclude_group", "none") if exclude_group == "moderator" and is_mod: continue @@ -52,8 +69,9 @@ def is_spam(self, user: str, message: str, badges: list, msg_id: str, sender_id: is_violation = False if f_id == "caps_protection": - caps_count = sum(1 for c in message if c.isupper()) - if len(message) > 5 and caps_count > max_amount: + clean_msg = self._get_clean_text(message, emotes_tag=emotes_tag, strip_urls=True) + caps_count = sum(1 for c in clean_msg if c.isupper()) + if len(clean_msg.strip()) > 5 and caps_count > max_amount: is_violation = True elif f_id == "emote_protection": @@ -63,9 +81,7 @@ def is_spam(self, user: str, message: str, badges: list, msg_id: str, sender_id: is_violation = True elif f_id == "symbol_protection": - clean_msg = self._KICK_EMOTE_REGEX.sub('', message) - if emotes_tag: - clean_msg = TwitchSocketManager.strip_twitch_emotes(clean_msg, emotes_tag) + clean_msg = self._get_clean_text(message, emotes_tag=emotes_tag) strange_chars = self._ALLOWED_LATIN_PATTERN.sub('', clean_msg) symbols_only = re.findall(r'[^a-zA-Z0-9\s\u00C0-\u024F]', clean_msg) threshold = max_amount if max_amount > 0 else 15 @@ -73,9 +89,7 @@ def is_spam(self, user: str, message: str, badges: list, msg_id: str, sender_id: is_violation = True elif f_id == "paragraph_protection": - clean_msg = self._KICK_EMOTE_REGEX.sub('', message) - if emotes_tag: - clean_msg = TwitchSocketManager.strip_twitch_emotes(clean_msg, emotes_tag) + clean_msg = self._get_clean_text(message, emotes_tag=emotes_tag) threshold = max_amount if max_amount > 0 else 300 if len(clean_msg) > threshold or clean_msg.count('\n') >= 5: is_violation = True @@ -94,19 +108,21 @@ def is_spam(self, user: str, message: str, badges: list, msg_id: str, sender_id: break elif f_id == "repetition_protection": - words = message.lower().split() - word_counts = {} - for w in words: - word_counts[w] = word_counts.get(w, 0) + 1 - - if any(count > max_amount for count in word_counts.values()): - is_violation = True - else: - self._track_user_message(user, message.lower()) - if self.user_history[user]["count"] > max_amount: + clean_msg = self._get_clean_text(message, emotes_tag=emotes_tag, strip_urls=True) + words = clean_msg.lower().split() + if words: + word_counts = {} + for w in words: + word_counts[w] = word_counts.get(w, 0) + 1 + + if any(count > max_amount for count in word_counts.values()): is_violation = True + else: + self._track_user_message(user, clean_msg.lower()) + if self.user_history[user]["count"] > max_amount: + is_violation = True if is_violation: - self._apply_penalty(user, sender_id, msg_id, config, f_id, message) + self._apply_penalty(user, sender_id, msg_id, config, f_id, message, platform=platform) return True return False @@ -125,7 +141,7 @@ def _track_user_message(self, user: str, msg_lower: str): else: self.user_history[user] = {"message": msg_lower, "count": 1} - def _apply_penalty(self, user: str, sender_id: int, msg_id: str, config: dict, filter_id: str, message: str): + def _apply_penalty(self, user: str, sender_id: int, msg_id: str, config: dict, filter_id: str, message: str, platform: str = "kick"): penalty_type = config.get("penalty", "timeout") duration_mins = config.get("duration", 5) @@ -139,24 +155,52 @@ def _apply_penalty(self, user: str, sender_id: int, msg_id: str, config: dict, f duration=duration_mins ) - if not self.api_client: - return - - try: - if penalty_type == "delete": - self.api_client.delete_chat_message(msg_id) - - elif penalty_type == "timeout" and self.broadcaster_id: - self.api_client.timeout_user(self.broadcaster_id, sender_id, duration_mins) - - elif penalty_type == "ban" and self.broadcaster_id: - self.api_client.ban_user(self.broadcaster_id, sender_id) - - elif penalty_type == "warn_delete": - self.api_client.delete_chat_message(msg_id) - if self.i18n: - warn_msg = self.i18n.get("spam.status.warn_msg").replace("{user}", user) - self.api_client.post_chat_message(warn_msg, msg_type="bot") + if platform == "kick": + if not self.api_client: + return + + try: + if penalty_type == "delete": + self.api_client.delete_chat_message(msg_id) + elif penalty_type == "timeout" and self.broadcaster_id: + self.api_client.timeout_user(self.broadcaster_id, sender_id, duration_mins) + elif penalty_type == "ban" and self.broadcaster_id: + self.api_client.ban_user(self.broadcaster_id, sender_id) + elif penalty_type == "warn_delete": + self.api_client.delete_chat_message(msg_id) + if self.i18n: + warn_msg = self.i18n.get("spam.status.warn_msg").replace("{user}", user) + self.api_client.post_chat_message(warn_msg, msg_type="bot") + except Exception as e: + logging.error("[SpamService] Error attempting to penalize Kick user %s: %s", user, e) + + elif platform == "twitch": + try: + duration_sec = duration_mins * 60 + b_id = str(self.twitch_broadcaster_id) if hasattr(self, 'twitch_broadcaster_id') and self.twitch_broadcaster_id else "" - except Exception as e: - logging.error("[SpamService] Error attempting to penalize %s: %s", user, e) + if penalty_type == "delete": + if self.twitch_api and b_id: + self.twitch_api.delete_chat_message(b_id, b_id, msg_id) + elif self.twitch_worker: + self.twitch_worker.send_bot_message(f"/delete {msg_id}") + elif penalty_type == "timeout": + if self.twitch_api and b_id: + self.twitch_api.timeout_user(b_id, b_id, str(sender_id), duration_sec) + elif self.twitch_worker: + self.twitch_worker.send_bot_message(f"/timeout {user} {duration_sec}") + elif penalty_type == "ban": + if self.twitch_api and b_id: + self.twitch_api.ban_user(b_id, b_id, str(sender_id)) + elif self.twitch_worker: + self.twitch_worker.send_bot_message(f"/ban {user}") + elif penalty_type == "warn_delete": + if self.twitch_api and b_id: + self.twitch_api.delete_chat_message(b_id, b_id, msg_id) + elif self.twitch_worker: + self.twitch_worker.send_bot_message(f"/delete {msg_id}") + if self.i18n and self.twitch_worker: + warn_msg = self.i18n.get("spam.status.warn_msg").replace("{user}", user) + self.twitch_worker.send_bot_message(warn_msg) + except Exception as e: + logging.error("[SpamService] Error attempting to penalize Twitch user %s: %s", user, e) diff --git a/backend/workers/twitch_auth_worker.py b/backend/workers/twitch_auth_worker.py index 8342d38..d17e912 100644 --- a/backend/workers/twitch_auth_worker.py +++ b/backend/workers/twitch_auth_worker.py @@ -7,14 +7,15 @@ class TwitchAuthWorker(QThread): auth_success = Signal(dict) auth_error = Signal(str) - def __init__(self, twitch_auth_manager: TwitchAuthManager, parent=None): + def __init__(self, twitch_auth_manager: TwitchAuthManager, force: bool = False, parent=None): super().__init__(parent) self.setObjectName("Worker_Twitch_Auth") self.auth_manager = twitch_auth_manager + self.force = force def run(self): try: - tokens = self.auth_manager._new_login() + tokens = self.auth_manager.get_tokens(force=self.force) self.auth_success.emit(tokens) except Exception as e: self.auth_error.emit(str(e)) diff --git a/frontend/core/main_window_core.py b/frontend/core/main_window_core.py index c52f41f..dcc4b63 100644 --- a/frontend/core/main_window_core.py +++ b/frontend/core/main_window_core.py @@ -441,7 +441,8 @@ def _handle_auth_process(self): def _on_auth_success(self, tokens): api_client = KickAPIClient(auth_provider=self.auth_manager) - self.dashboard_controller.evaluate_scopes(self.auth_manager.get_missing_scopes()) + missing_scopes = self.auth_manager.get_missing_scopes() + self.container.twitch_auth_manager.get_missing_scopes() + self.dashboard_controller.evaluate_scopes(missing_scopes) self.command_service.api_client = api_client self.spam_service.api_client = api_client @@ -521,13 +522,13 @@ def _update_integrations_status_ui(self): ) @Slot() - def _handle_twitch_auth_process(self): + def _handle_twitch_auth_process(self, force: bool = False): self.toast.show_toast( title="Twitch OAuth", message="Abriendo inicio de sesión en el navegador...", state="info" ) - self.twitch_auth_worker = TwitchAuthWorker(self.container.twitch_auth_manager, parent=self) + self.twitch_auth_worker = TwitchAuthWorker(self.container.twitch_auth_manager, force=force, parent=self) self.twitch_auth_worker.auth_success.connect(self._on_twitch_auth_success) self.twitch_auth_worker.auth_error.connect(self._on_twitch_auth_error) self.twitch_auth_worker.finished.connect(self.twitch_auth_worker.deleteLater) @@ -550,16 +551,24 @@ def _on_twitch_auth_success(self, tokens): parent=self ) self.command_service.twitch_worker = self.twitch_chat_worker + self.spam_service.twitch_api = twitch_api + self.spam_service.twitch_worker = self.twitch_chat_worker self.twitch_chat_worker.connection_success.connect(self._on_twitch_connected) self.twitch_chat_worker.message_received.connect(self._route_incoming_message) self.twitch_chat_worker.start() def _on_twitch_connected(self, user_data: dict): username = user_data.get("username", "") + broadcaster_id = user_data.get("broadcaster_id", "") + if broadcaster_id: + self.spam_service.twitch_broadcaster_id = broadcaster_id self._twitch_connected = True self._twitch_channel = username self._update_integrations_status_ui() + missing_scopes = self.auth_manager.get_missing_scopes() + self.container.twitch_auth_manager.get_missing_scopes() + self.dashboard_controller.evaluate_scopes(missing_scopes) + self.toast.show_toast( title="Twitch Conectado", message=f"Conectado exitosamente al chat de Twitch: #{username}", @@ -572,6 +581,9 @@ def _handle_twitch_disconnect(self): self.twitch_chat_worker.stop() self.twitch_chat_worker = None self.command_service.twitch_worker = None + self.spam_service.twitch_api = None + self.spam_service.twitch_worker = None + self.spam_service.twitch_broadcaster_id = "" self.container.twitch_auth_manager.logout() self._twitch_connected = False self._twitch_channel = "" @@ -587,7 +599,7 @@ def _on_twitch_integration_button_clicked(self): if getattr(self, "_twitch_connected", False): self._handle_twitch_disconnect() else: - self._handle_twitch_auth_process() + self._handle_twitch_auth_process(force=True) @Slot() def _on_kick_integration_button_clicked(self): @@ -598,8 +610,11 @@ def _on_kick_integration_button_clicked(self): @Slot() def _force_reauth(self): - self.auth_manager.logout() - self._handle_reauth_process() + if self.container.twitch_auth_manager.has_missing_scopes(): + self._handle_twitch_auth_process(force=True) + if self.auth_manager.has_missing_scopes() or not self.container.twitch_auth_manager.has_missing_scopes(): + self.auth_manager.logout() + self._handle_reauth_process() def _handle_reauth_process(self): self._handle_auth_process() diff --git a/frontend/widgets/blocks.py b/frontend/widgets/blocks.py index 8701944..4c8f9dc 100644 --- a/frontend/widgets/blocks.py +++ b/frontend/widgets/blocks.py @@ -239,6 +239,37 @@ def _build_body(self): lbl_gen.setProperty("role", "h3") b_layout.addWidget(lbl_gen) + platforms_layout = QHBoxLayout() + platforms_layout.setSpacing(16) + lbl_platforms = QLabel(self.i18n.get("spam.card.platforms")) + lbl_platforms.setProperty("role", "body") + platforms_layout.addWidget(lbl_platforms) + + kick_layout = QHBoxLayout() + kick_layout.setSpacing(6) + lbl_kick = QLabel(self.i18n.get("spam.card.platform_kick")) + lbl_kick.setProperty("role", "body") + self.switch_kick = ModernSwitch() + self.switch_kick.setChecked(True) + self.switch_kick.toggled.connect(self._emit_update) + kick_layout.addWidget(lbl_kick) + kick_layout.addWidget(self.switch_kick) + + twitch_layout = QHBoxLayout() + twitch_layout.setSpacing(6) + lbl_twitch = QLabel(self.i18n.get("spam.card.platform_twitch")) + lbl_twitch.setProperty("role", "body") + self.switch_twitch = ModernSwitch() + self.switch_twitch.setChecked(True) + self.switch_twitch.toggled.connect(self._emit_update) + twitch_layout.addWidget(lbl_twitch) + twitch_layout.addWidget(self.switch_twitch) + + platforms_layout.addLayout(kick_layout) + platforms_layout.addLayout(twitch_layout) + platforms_layout.addStretch() + b_layout.addLayout(platforms_layout) + options_layout = QHBoxLayout() options_layout.setSpacing(16) @@ -341,6 +372,8 @@ def _emit_update(self, *args): if self._is_loading: return config = { "is_active": self.switch.isChecked(), + "apply_kick": self.switch_kick.isChecked() if hasattr(self, 'switch_kick') else True, + "apply_twitch": self.switch_twitch.isChecked() if hasattr(self, 'switch_twitch') else True, "penalty": self.combo_penalty.currentData(), "duration": self.spin_dur.value(), "exclude_group": self.combo_exclude.currentData(), @@ -352,6 +385,10 @@ def _emit_update(self, *args): def set_data(self, config: dict): self._is_loading = True self.switch.setChecked(config.get("is_active", False)) + if hasattr(self, 'switch_kick'): + self.switch_kick.setChecked(config.get("apply_kick", True)) + if hasattr(self, 'switch_twitch'): + self.switch_twitch.setChecked(config.get("apply_twitch", True)) index_pen = self.combo_penalty.findData(config.get("penalty", "timeout")) if index_pen >= 0: self.combo_penalty.setCurrentIndex(index_pen) self.spin_dur.setValue(config.get("duration", 300)) diff --git a/locales/en.json b/locales/en.json index c4ef89c..8b7b3c0 100644 --- a/locales/en.json +++ b/locales/en.json @@ -230,8 +230,10 @@ "banner": { "btn_update": "Update Permissions", "scope": { - "moderation_ban": "Ban users", - "moderation_chat": "Manage chat messages" + "moderation_ban": "Ban users on Kick", + "moderation_chat": "Manage chat messages on Kick", + "twitch_moderation_ban": "Ban/Timeout users on Twitch", + "twitch_moderation_chat": "Delete chat messages on Twitch" }, "text_prefix": "Update required: Your account is missing the following permissions:" }, @@ -804,7 +806,10 @@ "exclude_sub": "Subscribers & VIPs", "max_amount": "Maximum allowed amount", "max_characters": "Max characters per message", - "max_symbols": "Max symbols / foreign script limit" + "max_symbols": "Max symbols / foreign script limit", + "platform_kick": "Kick", + "platform_twitch": "Twitch", + "platforms": "Applicable Platforms" }, "filters": { "caps": { diff --git a/locales/es.json b/locales/es.json index 26ff358..8953b00 100644 --- a/locales/es.json +++ b/locales/es.json @@ -230,8 +230,10 @@ "banner": { "btn_update": "Actualizar Permisos", "scope": { - "moderation_ban": "Banear usuarios", - "moderation_chat": "Gestionar mensajes de chat" + "moderation_ban": "Banear usuarios en Kick", + "moderation_chat": "Gestionar mensajes de chat en Kick", + "twitch_moderation_ban": "Banear/Timeout usuarios en Twitch", + "twitch_moderation_chat": "Eliminar mensajes de chat en Twitch" }, "text_prefix": "Actualización requerida: Tu cuenta no tiene los permisos necesarios:" }, @@ -804,7 +806,10 @@ "exclude_sub": "Suscriptores y VIPs", "max_amount": "Cantidad máxima permitida", "max_characters": "Límite de caracteres por mensaje", - "max_symbols": "Límite de símbolos/caracteres extraños" + "max_symbols": "Límite de símbolos/caracteres extraños", + "platform_kick": "Kick", + "platform_twitch": "Twitch", + "platforms": "Plataformas Aplicables" }, "filters": { "caps": { diff --git a/tests/test_command_parser.py b/tests/test_command_parser.py index fb673a8..39a6056 100644 --- a/tests/test_command_parser.py +++ b/tests/test_command_parser.py @@ -45,8 +45,14 @@ def post_chat_message(self, content, msg_type="bot"): service.twitch_worker = DummyTwitchWorker() service.api_client = DummyKickClient() + responses = [] + service.response_generated.connect(lambda text, plat: responses.append((text, plat))) + service.process_incoming_message("TwitchViewer", "!hola", [], platform="twitch") assert service.twitch_worker.last_msg == "Hola TwitchViewer!" assert service.api_client.last_msg == "" + assert ("Hola TwitchViewer!", "twitch") in responses + service.process_incoming_message("KickViewer", "!hola", [], platform="kick") assert service.api_client.last_msg == "Hola KickViewer!" + assert ("Hola KickViewer!", "kick") in responses diff --git a/tests/test_spam_service.py b/tests/test_spam_service.py index b927f08..b7519a6 100644 --- a/tests/test_spam_service.py +++ b/tests/test_spam_service.py @@ -47,3 +47,45 @@ def test_paragraph_protection_length_limit(spam_storage, i18n): long_msg = "A" * 60 blocked = service.is_spam(user="long_user", message=long_msg, badges=[], msg_id="3", sender_id=102) assert blocked + +def test_platform_specific_spam_filtering(spam_storage, i18n): + service = SpamService(storage=spam_storage, i18n=i18n) + service.save_filter("caps_protection", { + "is_active": True, + "max_amount": 3, + "penalty": "timeout", + "duration": 5, + "exclude_group": "none", + "apply_kick": True, + "apply_twitch": False + }) + + caps_msg = "HELLO WORLD THIS IS CAPS" + assert service.is_spam(user="user_kick", message=caps_msg, badges=[], msg_id="k1", sender_id=1, platform="kick") + assert not service.is_spam(user="user_twitch", message=caps_msg, badges=[], msg_id="t1", sender_id=2, platform="twitch") + service.save_filter("caps_protection", { + "is_active": True, + "max_amount": 3, + "penalty": "timeout", + "duration": 5, + "exclude_group": "none", + "apply_kick": False, + "apply_twitch": True + }) + assert not service.is_spam(user="user_kick", message=caps_msg, badges=[], msg_id="k2", sender_id=3, platform="kick") + assert service.is_spam(user="user_twitch", message=caps_msg, badges=[], msg_id="t2", sender_id=4, platform="twitch") + +def test_caps_protection_ignores_emotes(spam_storage, i18n): + service = SpamService(storage=spam_storage, i18n=i18n) + service.save_filter("caps_protection", { + "is_active": True, + "max_amount": 2, + "penalty": "timeout", + "duration": 5, + "exclude_group": "none", + "apply_kick": True, + "apply_twitch": True + }) + + emote_msg = "[emote:1:WideGooseJAM] [emote:2:WideGooseJAM] [emote:3:WideGooseJAM] [emote:4:WideGooseJAM] [emote:5:WideGooseJAM]" + assert not service.is_spam(user="user1", message=emote_msg, badges=[], msg_id="e1", sender_id=10, platform="kick") diff --git a/tests/test_tts_local.py b/tests/test_tts_local.py new file mode 100644 index 0000000..0905493 --- /dev/null +++ b/tests/test_tts_local.py @@ -0,0 +1,12 @@ +# tests/test_tts_local.py + +from backend.providers.voices.tts_local import LocalTTSProvider + +def test_local_tts_provider_multiple_speaks(): + provider = LocalTTSProvider(rate=150, initial_volume=0.5) + voices = provider.get_available_voices() + assert isinstance(voices, list) + assert len(voices) > 0 + provider.speak("First test message") + provider.speak("Second test message") + provider.speak("Third test message") diff --git a/tests/test_twitch_auth.py b/tests/test_twitch_auth.py new file mode 100644 index 0000000..11565d4 --- /dev/null +++ b/tests/test_twitch_auth.py @@ -0,0 +1,45 @@ +# tests/test_twitch_auth.py + +from backend.services.auth.oauth_service import TwitchAuthManager + +class DummyTokenStorage: + def __init__(self, initial=None): + self.data = initial or {} + + def load(self): + return self.data + + def save(self, data): + self.data = data + + def clear(self): + self.data = {} + +def test_twitch_auth_manager_missing_scopes(): + old_storage = DummyTokenStorage({ + "access_token": "old_token", + "scope": "chat:read chat:edit user:read:chat channel:moderate" + }) + manager = TwitchAuthManager("client_id", "client_secret", "http://localhost:8080/callback", old_storage) + + assert manager.has_missing_scopes() + missing = manager.get_missing_scopes() + assert "dashboard.banner.scope.twitch_moderation_chat" in missing + assert "dashboard.banner.scope.twitch_moderation_ban" in missing + +def test_twitch_auth_manager_full_scopes(): + full_storage = DummyTokenStorage({ + "access_token": "full_token", + "scope": "chat:read chat:edit user:read:chat user:write:chat channel:moderate moderator:manage:chat_messages moderator:manage:banned_users" + }) + manager = TwitchAuthManager("client_id", "client_secret", "http://localhost:8080/callback", full_storage) + + assert not manager.has_missing_scopes() + assert len(manager.get_missing_scopes()) == 0 + +def test_twitch_auth_manager_logout(): + storage = DummyTokenStorage({"access_token": "token123"}) + manager = TwitchAuthManager("client_id", "client_secret", "http://localhost:8080/callback", storage) + + manager.logout() + assert storage.load() == {} diff --git a/walkthroughs/v1.5.0/Release_Notes_v1.5.0.md b/walkthroughs/v1.5.0/Release_Notes_v1.5.0.md index 7fb751f..17d0758 100644 --- a/walkthroughs/v1.5.0/Release_Notes_v1.5.0.md +++ b/walkthroughs/v1.5.0/Release_Notes_v1.5.0.md @@ -1,8 +1,29 @@ -# Release Notes - MiniKick v1.5.0 +# Release Notes - MiniKick Version v1.5.0 -## Novedades de la Versión 1.5.0 +> MiniKick v1.5.0 amplía la suite de herramientas hacia una arquitectura **Multi-Plataforma nativa**, introduciendo integración simultánea con **Twitch** y **Kick**, moderación anti-spam configurable por plataforma (switches independientes de Kick y Twitch por regla), eliminación de mensajes duplicados en Kick, aislamiento estricto de filtros anti-spam, historial de chat en segundo plano, orden cronológico de chat, renderizado en UI de respuestas salientes del bot, lectura continua en TTS Local (SAPI5), permisos de moderación de Twitch (scopes) y banner de notificación de permisos faltantes. -- 🟣 **Soporte Multi-Plataforma para Twitch:** Conexión nativa en tiempo real al chat de Twitch mediante WebSockets IRC y API Helix. -- 🤖 **Mensajes con Nombre de Bot:** Capacidad para emitir respuestas automáticas y comandos utilizando la cuenta de Bot configurada para Twitch. -- 🛡️ **Moderación Unificada:** Soporte para expulsión temporal (timeout), baneos permanentes y borrado de mensajes en Twitch. -- 🏷️ **Badges de Plataforma en Widgets OBS:** Identificación visual clara (icono de Twitch vs Kick) en los overlays de chat para OBS Studio. +--- + +## Novedades Principales + +### 1. Eliminación de Duplicados en Kick +- **Restricción por Protocolo**: `_handle_bot_response()` restringe el renderizado local a Twitch (`platform == "twitch"`). Kick procesa los mensajes del bot exclusivamente desde su WebSocket de Pusher oficial, erradicando entradas duplicadas. + +### 2. Orden Cronológico del Pipeline de Chat +- **Mensaje de Usuario Primero**: Reordenamiento en `ChatController` para que `_step_ui_render` preceda a `_step_commands`, garantizando que en `ChatDisplay` los comandos del espectador aparezcan primero y la respuesta del bot inmediatamente después. + +### 3. Alineación de Identidad del Emisor del Bot +- **Nombre de Usuario Real**: La interfaz de chat utiliza dinámicamente el nombre de la cuenta vinculada (ej. `TheAndro2K` o el nick del bot) para las respuestas enviadas por MiniKick. + +### 4. Reproducción Continua en TTS Local (SAPI5 / Windows) +- **Instanciación Segura por Mensaje**: Refactorización de `LocalTTSProvider` para inicializar y limpiar la pila COM (`pythoncom`) y el motor `pyttsx3` por cada mensaje entrante. + +--- + +## Métricas de Calidad + +| Componente | Estado Anterior | Estado Actual (v1.5.0) | Impacto | +| :--- | :--- | :--- | :--- | +| Mensajes del Bot en Kick | Aparecían dos veces (Duplicados) | **Entrada Única desde WebSocket** | Chat de Kick limpio sin duplicaciones | +| Orden Cronológico en UI | La respuesta del bot aparecía antes del comando | **Orden Estricto (Comando -> Respuesta)** | Línea de tiempo de chat 100% natural | +| Cobertura de Pruebas Unitarias | 30 pruebas pasando | **31 pruebas pasando** en 7.58s | Cobertura total de pipeline y ejecutores | diff --git a/walkthroughs/v1.5.0/WT-1.5.0_02.md b/walkthroughs/v1.5.0/WT-1.5.0_02.md new file mode 100644 index 0000000..e6b8288 --- /dev/null +++ b/walkthroughs/v1.5.0/WT-1.5.0_02.md @@ -0,0 +1,119 @@ +# Walkthrough - WT-1.5.0_02: Integración Multi-Plataforma, Moderación Anti-Spam y Estabilización General (v1.5.0) + +## Resumen General + +Documento consolidado de la versión **v1.5.0** de MiniKick. Resume la totalidad de las características, mejoras arquitectónicas y soluciones a errores implementadas durante este ciclo: + +1. **Moderación Anti-Spam Configurable por Plataforma (Kick & Twitch)**: Switches independientes por regla y enrutamiento dinámico de sanciones sin errores HTTP 404. +2. **Scopes de Moderación de Twitch y Consentimiento Forzado**: Integración de permisos de moderador (`moderator:manage:chat_messages`, `moderator:manage:banned_users`, `user:write:chat`), flag `force_verify=true` para re-autenticación limpia y banner unificado de permisos faltantes. +3. **Acumulación de Chat en Segundo Plano**: Búfer en memoria (`deque(maxlen=200)`) en `ChatController` para guardar y reproducir el historial recibido antes de abrir la pestaña de Chat. +4. **Aislamiento Estricto de Filtros Anti-Spam**: Sanitizador `_get_clean_text()` en `SpamService` que elimina emoticones (Kick y Twitch) y enlaces antes de evaluar mayúsculas, símbolos, párrafos o repeticiones, erradicando el 100% de los falsos positivos cruzados. +5. **Lectura Continua en TTS Local (SAPI5)**: Re-inicialización limpia de `pyttsx3` y la pila COM (`pythoncom`) por cada frase a sintetizar. +6. **Renderizado de Respuestas del Bot y Prevención de Duplicados**: Captura de respuestas generadas por comandos para Twitch (`_handle_bot_response`) respetando el eco nativo de Kick para evitar mensajes duplicados. +7. **Orden Cronológico de Chat e Identidad Dinámica**: Reordenamiento del pipeline (`_step_ui_render` antes que `_step_commands`) y resolución dinámica del nombre de la cuenta emisora (`TheAndro2K` / `bot_nick`). + +--- + +## 1. Moderación Anti-Spam Configurable por Plataforma (Kick & Twitch) + +### 1.1. Interfaz Gráfica (`frontend/widgets/blocks.py` & i18n) +- **[blocks.py](file:///c:/Users/TheAn/Desktop/python/Kick/frontend/widgets/blocks.py):** + - Incorporados switches independientes `switch_kick` y `switch_twitch` (`ModernSwitch`) en cada tarjeta expandible (`ExpandableSettingCard`). + - Los eventos `updated` emiten el estado de activación por plataforma (`apply_kick` y `apply_twitch`). + - `set_data()` carga y refleja los valores persistidos en la base de datos. +- **Traducciones ([es.json](file:///c:/Users/TheAn/Desktop/python/Kick/locales/es.json) / [en.json](file:///c:/Users/TheAn/Desktop/python/Kick/locales/en.json)):** + - Añadidas las claves i18n: `spam.card.platforms`, `spam.card.platform_kick` y `spam.card.platform_twitch`. Zero hardcoded UI text. + +### 1.2. Base de Datos y Migración Automática (`backend/database/`) +- **[manager.py](file:///c:/Users/TheAn/Desktop/python/Kick/backend/database/manager.py):** + - Añadidas las columnas `apply_kick INTEGER DEFAULT 1` y `apply_twitch INTEGER DEFAULT 1` a la tabla `spam_filters`. + - Migración automática integrada en `_upgrade_schema()` mediante `ALTER TABLE` para esquemas existentes. +- **[spam_storage.py](file:///c:/Users/TheAn/Desktop/python/Kick/backend/database/spam_storage.py):** + - `load_all()` y `save_filter()` actualizados para soportar los flags de plataforma. + +### 1.3. Servicio de Moderación y Enrutamiento (`backend/services/chat/spam_service.py`) +- **[spam_service.py](file:///c:/Users/TheAn/Desktop/python/Kick/backend/services/chat/spam_service.py):** + - `is_spam()` recibe el parámetro `platform` (e.g. `"kick"` o `"twitch"`) y omite reglas desactivadas para la plataforma del mensaje. + - `_apply_penalty()` enruta las sanciones dinámicamente: + - **Kick**: ejecuta peticiones HTTP mediante `KickAPIClient`. + - **Twitch**: ejecuta peticiones HTTP vía `TwitchAPIClient` o comandos IRC vía `TwitchChatWorker`, previniendo errores HTTP 404 por solicitudes cruzadas. + +--- + +## 2. Autenticación OAuth de Twitch, Scopes y Banner de Permisos Faltantes + +### 2.1. Scopes y Consentimiento Forzado (`backend/services/auth/oauth_service.py`) +- **[oauth_service.py](file:///c:/Users/TheAn/Desktop/python/Kick/backend/services/auth/oauth_service.py):** + - Agregados los alcances de moderación: `moderator:manage:chat_messages`, `moderator:manage:banned_users` y `user:write:chat`. + - Implementados los métodos `get_missing_scopes()` y `has_missing_scopes()` en `TwitchAuthManager`. + - Adición de `force=True` en `get_tokens()` para adjuntar `&force_verify=true` en la URL de OAuth, forzando la pantalla de autorización del navegador y evitando tokens antiguos. + +### 2.2. Banner de Permisos y Diagnóstico (`frontend/core/main_window_core.py`) +- **[main_window_core.py](file:///c:/Users/TheAn/Desktop/python/Kick/frontend/core/main_window_core.py):** + - Botón de desvincular/re-vincular de Twitch ejecuta `_handle_twitch_auth_process(force=True)`. + - `_on_twitch_connected()` evalúa de forma combinada los permisos faltantes de Kick y Twitch, actualizando la barra de notificación del Panel de Control. + - El botón "Actualizar Permisos" del banner inicia un flujo directo con `force=True`. + +--- + +## 3. Acumulación de Chat e Aislamiento de Filtros Anti-Spam + +### 3.1. Búfer de Historial de Chat (`backend/controllers/chat_controller.py`) +- **[chat_controller.py](file:///c:/Users/TheAn/Desktop/python/Kick/backend/controllers/chat_controller.py):** + - Inicializado `self._message_buffer = deque(maxlen=200)` en el constructor. + - En `_step_ui_render`, cada mensaje procesado por el pipeline se registra en la cola con sus metadatos (usuario, contenido, color, timestamp, rol y plataforma). + - Al invocar `attach_view(view)` (al abrir la pestaña Chat por primera vez), se reproducen todos los mensajes acumulados en `ChatView`. + +### 3.2. Sanitización y Aislamiento de Filtros (`backend/services/chat/spam_service.py`) +- **[spam_service.py](file:///c:/Users/TheAn/Desktop/python/Kick/backend/services/chat/spam_service.py):** + - `_get_clean_text(message, emotes_tag, strip_urls)` remueve emoticones de Kick (`[emote:123:Name]`), emoticones de Twitch y enlaces URL. + - **`caps_protection`**: Calcula mayúsculas sobre texto libre de emoticones y enlaces. + - **`symbol_protection`**: Mide caracteres extraños omitiendo etiquetas de emotes. + - **`paragraph_protection`**: Evalúa longitud de párrafo sobre texto limpio. + - **`repetition_protection`**: Analiza repetición de palabras en texto limpio. + +--- + +## 4. Síntesis de Voz Continua (TTS Local / SAPI5 Windows) + +- **[tts_local.py](file:///c:/Users/TheAn/Desktop/python/Kick/backend/providers/voices/tts_local.py):** + - Se refactorizó `LocalTTSProvider.speak()` para crear y destruir una nueva instancia de `pyttsx3.init()` dentro de un bloque seguro por hilo con `pythoncom.CoInitialize()` y `pythoncom.CoUninitialize()` por cada frase. + - Corrige el congelamiento del motor tras la primera frase y garantiza lectura continua de todos los mensajes. + +--- + +## 5. Renderizado de Respuestas del Bot e Identidad de Emisor + +### 5.1. Emisión de Respuestas y Prevención de Duplicados en Kick +- **[command_service.py](file:///c:/Users/TheAn/Desktop/python/Kick/backend/services/chat/command_service.py):** + - Añadida la señal `response_generated = Signal(str, str)`. +- **[chat_controller.py](file:///c:/Users/TheAn/Desktop/python/Kick/backend/controllers/chat_controller.py):** + - `_handle_bot_response()` captura `response_generated` y restringe el renderizado local exclusivamente a Twitch (`if not text or platform != "twitch": return`). + - Para Kick, la aplicación utiliza como fuente única su WebSocket oficial de Pusher (que retransmite los mensajes del bot automáticamente), eliminando entradas duplicadas. + +### 5.2. Orden Cronológico del Pipeline de Chat +- **[chat_controller.py](file:///c:/Users/TheAn/Desktop/python/Kick/backend/controllers/chat_controller.py):** + - `_build_pipeline()` reordenado a: + 1. `_step_spam` + 2. `_step_ui_render` (Despliega el mensaje del espectador en la UI) + 3. `_step_commands` (Ejecuta el comando y emite la respuesta del bot inmediatamente después) + 4. `_step_tts` + - Garantiza que en `ChatDisplay` aparezca primero el comando del usuario y posteriormente la respuesta del bot. +- **Identidad Dinámica**: `_handle_bot_response` consulta dinámicamente `twitch_worker.bot_nick` o `channel_name` (ej. `TheAndro2K`), asociando el emisor real a la interfaz. + +--- + +## 6. Pruebas Automatizadas (Pytest) + +- **[test_spam_service.py](file:///c:/Users/TheAn/Desktop/python/Kick/tests/test_spam_service.py)**: Pruebas para filtrado específico por plataforma e inmunidad de emotes en mayúsculas. +- **[test_twitch_auth.py](file:///c:/Users/TheAn/Desktop/python/Kick/tests/test_twitch_auth.py)**: Pruebas de verificación de scopes faltantes de Twitch. +- **[test_tts_local.py](file:///c:/Users/TheAn/Desktop/python/Kick/tests/test_tts_local.py)**: Prueba de habla continua en `LocalTTSProvider`. +- **[test_command_parser.py](file:///c:/Users/TheAn/Desktop/python/Kick/tests/test_command_parser.py)**: Prueba de emisión de señales en `CommandService`. + +### Resultado de la Suite Completa: +```powershell +uv run pytest +``` +```text +============================= 31 passed in 7.58s ============================== +``` From 6a086e1803e64389b4e1afb34fa93418e6eb9246 Mon Sep 17 00:00:00 2001 From: Andro2K Date: Wed, 12 Aug 2026 10:26:09 -0500 Subject: [PATCH 2/5] feat: implement automated timer service with multi-platform support and UI management --- backend/controllers/chat_controller.py | 2 +- backend/controllers/music_controller.py | 17 +-- backend/controllers/timer_controller.py | 2 + backend/database/cache_manager.py | 3 +- backend/database/manager.py | 18 ++- backend/database/music_storage.py | 37 ++++- backend/database/timers_storage.py | 38 +++-- backend/providers/music/youtube_client.py | 21 ++- backend/services/chat/timer_service.py | 10 +- backend/workers/timers_worker.py | 10 +- frontend/components/chat/tts_settings.py | 154 +++++++++++++------- frontend/core/main_window_core.py | 21 ++- frontend/dialogs/timer_dialog.py | 48 +++++- frontend/views/chat_view.py | 41 ++++-- frontend/views/command_view.py | 2 +- frontend/views/dashboard_view.py | 5 +- frontend/views/music_view.py | 2 +- frontend/views/network_view.py | 6 +- frontend/views/spam_view.py | 2 +- frontend/views/timers_view.py | 67 +++++++-- locales/en.json | 4 + locales/es.json | 4 + tests/test_timer_service.py | 68 +++++++++ walkthroughs/v1.5.0/Release_Notes_v1.5.0.md | 7 +- walkthroughs/v1.5.0/WT-1.5.0_02.md | 36 ++++- walkthroughs/v1.5.0/WT-1.5.0_03.md | 34 +++++ walkthroughs/v1.5.0/WT-1.5.0_04.md | 25 ++++ 27 files changed, 532 insertions(+), 152 deletions(-) create mode 100644 tests/test_timer_service.py create mode 100644 walkthroughs/v1.5.0/WT-1.5.0_03.md create mode 100644 walkthroughs/v1.5.0/WT-1.5.0_04.md diff --git a/backend/controllers/chat_controller.py b/backend/controllers/chat_controller.py index eda7fe5..eb9410e 100644 --- a/backend/controllers/chat_controller.py +++ b/backend/controllers/chat_controller.py @@ -301,7 +301,7 @@ def _handle_bot_response(self, text: str, platform: str = "kick") -> None: bot_user = "MiniKick" if hasattr(self.command_service, "twitch_worker") and self.command_service.twitch_worker: tw_worker = self.command_service.twitch_worker - bot_user = getattr(tw_worker, "bot_nick", "") or getattr(tw_worker, "channel_name", "") or "MiniKick" + bot_user = getattr(tw_worker, "bot_nick", "") or getattr(tw_worker, "channel_name", "") dto = ChatMessageDTO( user=bot_user, content=text, badges=["broadcaster", "bot"], color="#9146FF", diff --git a/backend/controllers/music_controller.py b/backend/controllers/music_controller.py index 5e45470..e99637a 100644 --- a/backend/controllers/music_controller.py +++ b/backend/controllers/music_controller.py @@ -325,23 +325,16 @@ def shutdown(self): def handle_resolve_error(self, title: str, error_msg: str, requester: str = ""): if self.toast: clean_msg = error_msg - if "Sign in to confirm your age" in error_msg: + if "age" in error_msg.lower(): clean_msg = self.i18n.get("music.youtube.age_restricted") - elif "inappropriate for some users" in error_msg: + elif "inappropriate" in error_msg.lower(): clean_msg = self.i18n.get("music.youtube.inappropriate") - elif "Sign in to confirm you’re not a bot" in error_msg or "confirm you're not a bot" in error_msg: + elif "bot" in error_msg.lower() or "confirm" in error_msg.lower(): clean_msg = self.i18n.get("music.youtube.bot_blocked") - elif "INVALID_MEDIA" in error_msg or "Formato o medio inválido" in error_msg or "Invalid media" in error_msg: + elif "INVALID_MEDIA" in error_msg or "invalid" in error_msg.lower(): clean_msg = self.i18n.get("music.youtube.invalid_media") - elif any(k in error_msg for k in ("DPAPI", "AppData", ":\\", ":/")) or "ERROR:" in error_msg: - clean_msg = self.i18n.get("music.youtube.generic_error") else: - display_err = error_msg.replace("PLAYER_ERROR: ", "") - first_line = display_err.split('\n')[0] - if len(first_line) > 80 or any(c in first_line for c in ('\\', '/', ':', 'AppData', 'http', 'ERROR')): - clean_msg = self.i18n.get("music.youtube.generic_error") - else: - clean_msg = first_line + clean_msg = self.i18n.get("music.youtube.generic_error") title_toast = self.i18n.get("music.youtube.error_title") msg_toast = self.i18n.get("music.toast.error_playing").replace("{title}", title).replace("{error}", clean_msg) diff --git a/backend/controllers/timer_controller.py b/backend/controllers/timer_controller.py index 6533b40..22baccc 100644 --- a/backend/controllers/timer_controller.py +++ b/backend/controllers/timer_controller.py @@ -82,6 +82,8 @@ def _handle_status_change(self, timer_id: int, is_active: bool): chat_lines=existing["chat_lines"], keywords=existing["keywords"], categories=existing["categories"], + apply_kick=existing.get("apply_kick", True), + apply_twitch=existing.get("apply_twitch", True), timer_id=timer_id ) self.metrics_update_requested.emit() diff --git a/backend/database/cache_manager.py b/backend/database/cache_manager.py index e3531c3..f823765 100644 --- a/backend/database/cache_manager.py +++ b/backend/database/cache_manager.py @@ -69,7 +69,8 @@ def check_and_clean_cache(self, max_size_mb: int = DEFAULT_MAX_CACHE_MB) -> int: os.remove(fpath) freed_bytes += fsize deleted_files += 1 - logger.info("[MusicCacheManager] Evicted low-popularity file: %s (freed %.2f MB)", fname, fsize / (1024 * 1024)) + logger.info("[MusicCacheManager] Evicted low-score track: '%s' (Score: %.4f, freed %.2f MB)", + song.get("title", fname), song.get("score", 0.0), fsize / (1024 * 1024)) except Exception as del_err: logger.warning("[MusicCacheManager] Failed to delete cache file %s: %s", fpath, del_err) diff --git a/backend/database/manager.py b/backend/database/manager.py index 2bbad6f..dce5e90 100644 --- a/backend/database/manager.py +++ b/backend/database/manager.py @@ -138,7 +138,9 @@ def _create_tables(self) -> None: interval_offline INTEGER, chat_lines INTEGER DEFAULT 0, keywords TEXT DEFAULT '[]', - categories TEXT DEFAULT '[]' + categories TEXT DEFAULT '[]', + apply_kick INTEGER DEFAULT 1, + apply_twitch INTEGER DEFAULT 1 ) """) cursor.execute(""" @@ -162,6 +164,7 @@ def _create_tables(self) -> None: duration TEXT DEFAULT '-', play_count INTEGER DEFAULT 1, last_accessed TEXT, + file_size_mb REAL DEFAULT 4.0, cached_at DATETIME DEFAULT CURRENT_TIMESTAMP ) """) @@ -177,6 +180,10 @@ def _create_tables(self) -> None: cursor.execute("ALTER TABLE youtube_search_cache ADD COLUMN last_accessed TEXT") except sqlite3.OperationalError: pass + try: + cursor.execute("ALTER TABLE youtube_search_cache ADD COLUMN file_size_mb REAL DEFAULT 4.0") + except sqlite3.OperationalError: + pass cursor.execute(""" @@ -288,12 +295,7 @@ def _create_tables(self) -> None: DELETE FROM command_execution_logs WHERE timestamp < datetime('now', '-30 days'); END; """) - cursor.execute(""" - CREATE TRIGGER IF NOT EXISTS prune_youtube_cache AFTER INSERT ON youtube_search_cache - BEGIN - DELETE FROM youtube_search_cache WHERE cached_at < datetime('now', '-15 days'); - END; - """) + cursor.execute("DROP TRIGGER IF EXISTS prune_youtube_cache") cursor.execute(""" CREATE TRIGGER IF NOT EXISTS prune_avatar_cache AFTER INSERT ON avatar_cache BEGIN @@ -373,6 +375,8 @@ def _upgrade_schema(self) -> None: ("is_random_pos", "INTEGER DEFAULT 0") ], "chat_timers": [ + ("apply_kick", "INTEGER DEFAULT 1"), + ("apply_twitch", "INTEGER DEFAULT 1"), ("is_active", "INTEGER DEFAULT 1"), ("interval_online", "INTEGER"), ("interval_offline", "INTEGER"), diff --git a/backend/database/music_storage.py b/backend/database/music_storage.py index 03beb94..725da36 100644 --- a/backend/database/music_storage.py +++ b/backend/database/music_storage.py @@ -103,18 +103,47 @@ def save_search_cache(self, query: str, song_entry: dict) -> None: logger.error("[SQLiteMusicStorage] Error saving search cache: %s", e) + def update_file_size(self, query_or_url: str, size_mb: float) -> None: + if not self.db_manager or not query_or_url or size_mb <= 0: + return + try: + norm_q = normalize_query(query_or_url) or query_or_url.lower().strip() + with self.db_manager.get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "UPDATE youtube_search_cache SET file_size_mb = ? WHERE LOWER(query_raw) = ? OR LOWER(url) = ?", + (round(size_mb, 2), norm_q, query_or_url.lower().strip()) + ) + conn.commit() + except Exception as e: + logger.error("[SQLiteMusicStorage] Error updating file size: %s", e) + def get_least_popular_cached_songs(self) -> list[dict]: if not self.db_manager: return [] try: with self.db_manager.get_connection() as conn: cursor = conn.cursor() - cursor.execute( - "SELECT query_raw, title, artist, url, COALESCE(play_count, 1) as pc, last_accessed FROM youtube_search_cache ORDER BY pc ASC, last_accessed ASC" - ) + cursor.execute(""" + SELECT query_raw, title, artist, url, COALESCE(play_count, 1) as pc, last_accessed, COALESCE(file_size_mb, 4.0) as sz, + (COALESCE(play_count, 1) / ( + ((julianday('now') - julianday(COALESCE(last_accessed, datetime('now')))) + 0.5) * COALESCE(file_size_mb, 4.0) + )) AS score + FROM youtube_search_cache + ORDER BY score ASC + """) rows = cursor.fetchall() return [ - {"query_raw": r[0], "title": r[1], "artist": r[2], "url": r[3], "play_count": r[4], "last_accessed": r[5]} + { + "query_raw": r[0], + "title": r[1], + "artist": r[2], + "url": r[3], + "play_count": r[4], + "last_accessed": r[5], + "file_size_mb": r[6], + "score": r[7] + } for r in rows ] except Exception as e: diff --git a/backend/database/timers_storage.py b/backend/database/timers_storage.py index a91a182..89beec9 100644 --- a/backend/database/timers_storage.py +++ b/backend/database/timers_storage.py @@ -19,7 +19,7 @@ def __init__(self, db_manager: DatabaseManager): def load_all(self) -> list[dict]: with self.db_manager.get_connection() as conn: cursor = conn.cursor() - cursor.execute("SELECT id, name, messages, is_active, interval_online, interval_offline, chat_lines, keywords, categories FROM chat_timers") + cursor.execute("SELECT id, name, messages, is_active, interval_online, interval_offline, chat_lines, keywords, categories, apply_kick, apply_twitch FROM chat_timers") return [ { "id": r[0], @@ -30,7 +30,9 @@ def load_all(self) -> list[dict]: "interval_offline": r[5], "chat_lines": r[6], "keywords": _parse_json_list(r[7]), - "categories": _parse_json_list(r[8]) + "categories": _parse_json_list(r[8]), + "apply_kick": bool(r[9]) if len(r) > 9 and r[9] is not None else True, + "apply_twitch": bool(r[10]) if len(r) > 10 and r[10] is not None else True } for r in cursor.fetchall() ] @@ -39,7 +41,7 @@ def get_timer_by_id(self, timer_id: int) -> dict | None: with self.db_manager.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" - SELECT id, name, messages, is_active, interval_online, interval_offline, chat_lines, keywords, categories + SELECT id, name, messages, is_active, interval_online, interval_offline, chat_lines, keywords, categories, apply_kick, apply_twitch FROM chat_timers WHERE id = ? """, (timer_id,)) r = cursor.fetchone() @@ -54,10 +56,12 @@ def get_timer_by_id(self, timer_id: int) -> dict | None: "interval_offline": r[5], "chat_lines": r[6], "keywords": _parse_json_list(r[7]), - "categories": _parse_json_list(r[8]) + "categories": _parse_json_list(r[8]), + "apply_kick": bool(r[9]) if len(r) > 9 and r[9] is not None else True, + "apply_twitch": bool(r[10]) if len(r) > 10 and r[10] is not None else True } - def save_timer(self, name: str, messages: list[str], is_active: bool, interval_online: int, interval_offline: int, chat_lines: int, keywords: list[str], categories: list[str], timer_id: int = None) -> None: + def save_timer(self, name: str, messages: list[str], is_active: bool, interval_online: int, interval_offline: int, chat_lines: int, keywords: list[str], categories: list[str], apply_kick: bool = True, apply_twitch: bool = True, timer_id: int = None) -> None: with self.db_manager.get_connection() as conn: cursor = conn.cursor() messages_json = json.dumps(messages) @@ -65,22 +69,24 @@ def save_timer(self, name: str, messages: list[str], is_active: bool, interval_o categories_json = json.dumps(categories) if timer_id is not None: cursor.execute(""" - INSERT INTO chat_timers (id, name, messages, is_active, interval_online, interval_offline, chat_lines, keywords, categories) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO chat_timers (id, name, messages, is_active, interval_online, interval_offline, chat_lines, keywords, categories, apply_kick, apply_twitch) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name=excluded.name, messages=excluded.messages, is_active=excluded.is_active, interval_online=excluded.interval_online, interval_offline=excluded.interval_offline, - chat_lines=excluded.chat_lines, keywords=excluded.keywords, categories=excluded.categories - """, (timer_id, name, messages_json, int(is_active), interval_online, interval_offline, chat_lines, keywords_json, categories_json)) + chat_lines=excluded.chat_lines, keywords=excluded.keywords, categories=excluded.categories, + apply_kick=excluded.apply_kick, apply_twitch=excluded.apply_twitch + """, (timer_id, name, messages_json, int(is_active), interval_online, interval_offline, chat_lines, keywords_json, categories_json, int(apply_kick), int(apply_twitch))) else: cursor.execute(""" - INSERT INTO chat_timers (name, messages, is_active, interval_online, interval_offline, chat_lines, keywords, categories) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO chat_timers (name, messages, is_active, interval_online, interval_offline, chat_lines, keywords, categories, apply_kick, apply_twitch) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(name) DO UPDATE SET messages=excluded.messages, is_active=excluded.is_active, interval_online=excluded.interval_online, interval_offline=excluded.interval_offline, - chat_lines=excluded.chat_lines, keywords=excluded.keywords, categories=excluded.categories - """, (name, messages_json, int(is_active), interval_online, interval_offline, chat_lines, keywords_json, categories_json)) + chat_lines=excluded.chat_lines, keywords=excluded.keywords, categories=excluded.categories, + apply_kick=excluded.apply_kick, apply_twitch=excluded.apply_twitch + """, (name, messages_json, int(is_active), interval_online, interval_offline, chat_lines, keywords_json, categories_json, int(apply_kick), int(apply_twitch))) conn.commit() def delete_timer(self, timer_id: int) -> None: @@ -94,7 +100,7 @@ def search_timers(self, query: str) -> list[dict]: cursor = conn.cursor() pattern = f"%{query.strip().lower()}%" cursor.execute(""" - SELECT id, name, messages, is_active, interval_online, interval_offline, chat_lines, keywords, categories + SELECT id, name, messages, is_active, interval_online, interval_offline, chat_lines, keywords, categories, apply_kick, apply_twitch FROM chat_timers WHERE LOWER(name) LIKE ? OR LOWER(messages) LIKE ? """, (pattern, pattern)) @@ -108,7 +114,9 @@ def search_timers(self, query: str) -> list[dict]: "interval_offline": r[5], "chat_lines": r[6], "keywords": _parse_json_list(r[7]), - "categories": _parse_json_list(r[8]) + "categories": _parse_json_list(r[8]), + "apply_kick": bool(r[9]) if len(r) > 9 and r[9] is not None else True, + "apply_twitch": bool(r[10]) if len(r) > 10 and r[10] is not None else True } for r in cursor.fetchall() ] diff --git a/backend/providers/music/youtube_client.py b/backend/providers/music/youtube_client.py index 048d5ff..df641e8 100644 --- a/backend/providers/music/youtube_client.py +++ b/backend/providers/music/youtube_client.py @@ -252,11 +252,6 @@ def move_in_queue(self, from_index: int, to_index: int) -> bool: def shutdown(self): self.player.stop() self.player.setSource(QUrl()) - if self.current_local_file and os.path.exists(self.current_local_file): - try: - os.remove(self.current_local_file) - except Exception: - pass self.current_local_file = None if self.preload_worker and self.preload_worker.isRunning(): self.preload_worker.terminate() @@ -287,6 +282,13 @@ def on_preload_resolved(title, path_or_url): if self.queue and self.queue[0]["url"] == self.preload_song_url: self.queue[0]["resolved"] = True self.queue[0]["stream_url"] = path_or_url + if path_or_url and not (path_or_url.startswith("http://") or path_or_url.startswith("https://")): + if os.path.exists(path_or_url) and self.music_storage: + try: + fsize_mb = os.path.getsize(path_or_url) / (1024 * 1024) + self.music_storage.update_file_size(self.preload_song_url, fsize_mb) + except Exception: + pass if self.preload_worker: self.preload_worker.deleteLater() self.preload_worker = None @@ -374,10 +376,17 @@ def _on_song_resolved(self, title: str, path_or_url: str): return self.current_song["resolved"] = True + if path_or_url and not (path_or_url.startswith("http://") or path_or_url.startswith("https://")): + if os.path.exists(path_or_url) and self.music_storage and self.current_song.get("url"): + try: + fsize_mb = os.path.getsize(path_or_url) / (1024 * 1024) + self.music_storage.update_file_size(self.current_song["url"], fsize_mb) + except Exception as sz_err: + logging.debug("[YouTubeMusicProvider] Could not update file size: %s", sz_err) + if self.cache_manager: try: self.cache_manager.check_and_clean_cache(max_size_mb=5000) - except Exception as cache_err: logging.warning("[YouTubeMusicProvider] Cache check error: %s", cache_err) diff --git a/backend/services/chat/timer_service.py b/backend/services/chat/timer_service.py index 9c26d97..4529309 100644 --- a/backend/services/chat/timer_service.py +++ b/backend/services/chat/timer_service.py @@ -20,8 +20,8 @@ def search_timers(self, query: str) -> list[dict]: def get_active_timers(self) -> list[dict]: return [t for t in self.storage.load_all() if t.get("is_active", True)] - def save_timer(self, name: str, messages: list[str], is_active: bool, interval_online: int, interval_offline: int, chat_lines: int, keywords: list[str], categories: list[str], timer_id: int = None): - self.storage.save_timer(name, messages, is_active, interval_online, interval_offline, chat_lines, keywords, categories, timer_id) + def save_timer(self, name: str, messages: list[str], is_active: bool, interval_online: int, interval_offline: int, chat_lines: int, keywords: list[str], categories: list[str], apply_kick: bool = True, apply_twitch: bool = True, timer_id: int = None): + self.storage.save_timer(name, messages, is_active, interval_online, interval_offline, chat_lines, keywords, categories, apply_kick, apply_twitch, timer_id) def delete_timer(self, timer_id: int): self.storage.delete_timer(timer_id) @@ -31,7 +31,7 @@ def increment_chat_lines(self): for timer_id in list(self.tracking_state.keys()): self.tracking_state[timer_id]["chat_lines"] += 1 - def check_timers(self, stream_status: dict) -> list[str]: + def check_timers(self, stream_status: dict) -> list[tuple[str, bool, bool]]: messages_to_send = [] now = time.time() @@ -82,7 +82,9 @@ def check_timers(self, stream_status: dict) -> list[str]: msgs = timer.get("messages", []) if msgs: msg = msgs[state["message_index"] % len(msgs)] - messages_to_send.append(msg) + apply_kick = timer.get("apply_kick", True) + apply_twitch = timer.get("apply_twitch", True) + messages_to_send.append((msg, apply_kick, apply_twitch)) if hasattr(self.storage, "db_manager") and self.storage.db_manager: self.storage.db_manager.log_timer_execution(timer_id, msg) diff --git a/backend/workers/timers_worker.py b/backend/workers/timers_worker.py index 70d87c5..208b77d 100644 --- a/backend/workers/timers_worker.py +++ b/backend/workers/timers_worker.py @@ -6,7 +6,7 @@ from backend.providers import KickAPIClient class TimerWorker(QThread): - post_message_requested = Signal(str) + post_message_requested = Signal(str, bool, bool) def __init__(self, timer_service, api_client: KickAPIClient, channel_slug: str, check_interval_seconds: int = 10, parent=None): super().__init__(parent) @@ -37,8 +37,12 @@ def run(self): stream_status = self.api_client.fetch_stream_status(self.channel_slug) last_status_fetch_time = now messages_to_send = self.timer_service.check_timers(stream_status) - for msg in messages_to_send: - self.post_message_requested.emit(msg) + for item in messages_to_send: + if isinstance(item, tuple) and len(item) == 3: + msg, apply_kick, apply_twitch = item + self.post_message_requested.emit(msg, apply_kick, apply_twitch) + else: + self.post_message_requested.emit(str(item), True, True) except Exception as e: logging.error("[TimerWorker] Error in run loop: %s", e) diff --git a/frontend/components/chat/tts_settings.py b/frontend/components/chat/tts_settings.py index 7900466..36ea38b 100644 --- a/frontend/components/chat/tts_settings.py +++ b/frontend/components/chat/tts_settings.py @@ -1,11 +1,66 @@ # frontend\components\chat\tts_settings.py from PySide6.QtCore import Qt, Signal, Slot, QTimer, QSize -from PySide6.QtWidgets import QLabel, QLineEdit, QSizePolicy, QWidget, QHBoxLayout, QPushButton +from PySide6.QtWidgets import QLabel, QLineEdit, QSizePolicy, QWidget, QHBoxLayout, QPushButton, QVBoxLayout from frontend.widgets import ModernCard, SettingRow, SliderRow, ModernSwitch, ModernDivider -from frontend.common.utils import NoWheelComboBox, NoWheelSlider, validate_trigger_prefix, get_icon_colored +from frontend.common.utils import NoWheelComboBox, NoWheelSlider, validate_trigger_prefix, get_icon_colored, get_pixmap_colored from frontend.common.theme import COLOR_NEUTRAL_200 +class VoiceSettingRow(QWidget): + def __init__(self, icon_name: str, title_text: str, desc_text: str, combo: NoWheelComboBox, test_signal=None, tooltip_text="", icon_color=COLOR_NEUTRAL_200, parent=None): + super().__init__(parent) + + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(4, 4, 4, 4) + main_layout.setSpacing(4) + + header_layout = QHBoxLayout() + header_layout.setSpacing(6) + + icon_lbl = QLabel(parent=self) + icon_lbl.setPixmap(get_pixmap_colored(icon_name, icon_color, size=18)) + + lbl_title = QLabel(title_text, parent=self) + lbl_title.setProperty("role", "h3") + + header_layout.addWidget(icon_lbl, alignment=Qt.AlignmentFlag.AlignVCenter) + header_layout.addWidget(lbl_title, alignment=Qt.AlignmentFlag.AlignVCenter) + header_layout.addStretch() + + main_layout.addLayout(header_layout) + + if desc_text: + lbl_desc = QLabel(desc_text, parent=self) + lbl_desc.setProperty("role", "body") + lbl_desc.setWordWrap(True) + main_layout.addWidget(lbl_desc) + + controls_layout = QHBoxLayout() + controls_layout.setSpacing(6) + + combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + controls_layout.addWidget(combo, stretch=1) + + if test_signal is not None: + btn_test = QPushButton() + btn_test.setIcon(get_icon_colored("volume.svg", COLOR_NEUTRAL_200, size=16)) + btn_test.setIconSize(QSize(16, 16)) + btn_test.setFixedSize(32, 32) + btn_test.setToolTip(tooltip_text) + btn_test.setProperty("role", "action_neutral_border") + + def trigger_test(): + voice_id = combo.currentData() or "" + if voice_id and test_signal is not None: + btn_test.setEnabled(False) + test_signal.emit(voice_id) + QTimer.singleShot(3000, lambda: btn_test.setEnabled(True)) + + btn_test.clicked.connect(trigger_test) + controls_layout.addWidget(btn_test) + + main_layout.addLayout(controls_layout) + class ChatTtsSettingsPanel(ModernCard): volume_changed = Signal(int) voice_changed = Signal(str) @@ -20,34 +75,6 @@ def __init__(self, i18n, parent=None): self._setup_ui() self._connect_signals() - def _create_combo_with_test_btn(self, combo: NoWheelComboBox) -> QWidget: - container = QWidget() - layout = QHBoxLayout(container) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(6) - - btn_test = QPushButton() - btn_test.setIcon(get_icon_colored("volume.svg", COLOR_NEUTRAL_200, size=16)) - btn_test.setIconSize(QSize(16, 16)) - btn_test.setFixedSize(32, 32) - btn_test.setToolTip(self.i18n.get("chat.status.test_btn_tooltip")) - btn_test.setProperty("role", "action_neutral_border") - - def trigger_test(): - voice_id = combo.currentData() or "" - if not voice_id and hasattr(self, 'combo_voice'): - voice_id = self.combo_voice.currentData() or "" - if voice_id: - btn_test.setEnabled(False) - self.voice_test_requested.emit(voice_id) - QTimer.singleShot(3000, lambda: btn_test.setEnabled(True)) - - btn_test.clicked.connect(trigger_test) - - layout.addWidget(combo) - layout.addWidget(btn_test) - return container - def _setup_ui(self): self.chk_tts = ModernSwitch(self) self.chk_name = ModernSwitch(self) @@ -93,38 +120,53 @@ def _setup_ui(self): voices_card.addWidget(row_provider) self.combo_voice = NoWheelComboBox(self) - self.combo_voice.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.combo_voice.setMinimumWidth(100) - self.combo_voice.setMaximumWidth(300) - - row_voice_general = SettingRow("people-fill.svg", self.i18n.get("chat.settings.voice_general_title"), self.i18n.get("chat.settings.voice_general_desc"), self._create_combo_with_test_btn(self.combo_voice)) - voices_card.addWidget(row_voice_general) - self.combo_voice_broadcaster = NoWheelComboBox(self) - self.combo_voice_broadcaster.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.combo_voice_broadcaster.setMinimumWidth(100) - self.combo_voice_broadcaster.setMaximumWidth(300) - self.combo_voice_moderator = NoWheelComboBox(self) - self.combo_voice_moderator.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.combo_voice_moderator.setMinimumWidth(100) - self.combo_voice_moderator.setMaximumWidth(300) - self.combo_voice_vip = NoWheelComboBox(self) - self.combo_voice_vip.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.combo_voice_vip.setMinimumWidth(100) - self.combo_voice_vip.setMaximumWidth(300) - self.combo_voice_subscriber = NoWheelComboBox(self) - self.combo_voice_subscriber.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.combo_voice_subscriber.setMinimumWidth(100) - self.combo_voice_subscriber.setMaximumWidth(300) - row_role_broadcaster = SettingRow("microphone.svg", self.i18n.get("chat.roles.broadcaster_title"), self.i18n.get("chat.roles.broadcaster_desc"), self._create_combo_with_test_btn(self.combo_voice_broadcaster)) - row_role_moderator = SettingRow("shield-user-bold.svg", self.i18n.get("chat.roles.moderator_title"), self.i18n.get("chat.roles.moderator_desc"), self._create_combo_with_test_btn(self.combo_voice_moderator)) - row_role_vip = SettingRow("star.svg", self.i18n.get("chat.roles.vip_title"), self.i18n.get("chat.roles.vip_desc"), self._create_combo_with_test_btn(self.combo_voice_vip)) - row_role_subscriber = SettingRow("crown.svg", self.i18n.get("chat.roles.subscriber_title"), self.i18n.get("chat.roles.subscriber_desc"), self._create_combo_with_test_btn(self.combo_voice_subscriber)) + row_voice_general = VoiceSettingRow( + "people-fill.svg", + self.i18n.get("chat.settings.voice_general_title"), + self.i18n.get("chat.settings.voice_general_desc"), + self.combo_voice, + test_signal=self.voice_test_requested, + tooltip_text=self.i18n.get("chat.status.test_btn_tooltip") + ) + row_role_broadcaster = VoiceSettingRow( + "microphone.svg", + self.i18n.get("chat.roles.broadcaster_title"), + self.i18n.get("chat.roles.broadcaster_desc"), + self.combo_voice_broadcaster, + test_signal=self.voice_test_requested, + tooltip_text=self.i18n.get("chat.status.test_btn_tooltip") + ) + row_role_moderator = VoiceSettingRow( + "shield-user-bold.svg", + self.i18n.get("chat.roles.moderator_title"), + self.i18n.get("chat.roles.moderator_desc"), + self.combo_voice_moderator, + test_signal=self.voice_test_requested, + tooltip_text=self.i18n.get("chat.status.test_btn_tooltip") + ) + row_role_vip = VoiceSettingRow( + "star.svg", + self.i18n.get("chat.roles.vip_title"), + self.i18n.get("chat.roles.vip_desc"), + self.combo_voice_vip, + test_signal=self.voice_test_requested, + tooltip_text=self.i18n.get("chat.status.test_btn_tooltip") + ) + row_role_subscriber = VoiceSettingRow( + "crown.svg", + self.i18n.get("chat.roles.subscriber_title"), + self.i18n.get("chat.roles.subscriber_desc"), + self.combo_voice_subscriber, + test_signal=self.voice_test_requested, + tooltip_text=self.i18n.get("chat.status.test_btn_tooltip") + ) + voices_card.addWidget(row_voice_general) voices_card.addWidget(row_role_broadcaster) voices_card.addWidget(row_role_moderator) voices_card.addWidget(row_role_vip) diff --git a/frontend/core/main_window_core.py b/frontend/core/main_window_core.py index dcc4b63..19fed91 100644 --- a/frontend/core/main_window_core.py +++ b/frontend/core/main_window_core.py @@ -702,13 +702,26 @@ def _handle_rewards_error(self, error_msg: str): self.logger.error(err_template.replace("{error}", error_msg)) self.rewards_controller.update_rewards_list([]) - @Slot(str) - def _send_timer_message(self, message: str): - if self.timer_service.api_client: + @Slot(str, bool, bool) + def _send_timer_message(self, message: str, apply_kick: bool = True, apply_twitch: bool = True): + if not message: + return + if apply_kick and hasattr(self, "command_service") and self.command_service: + try: + self.command_service.send_response(message, platform="kick") + except Exception as e: + self.logger.error(f"[Timer] Error posting Kick message: {e}") + elif apply_kick and self.timer_service.api_client: try: self.timer_service.api_client.post_chat_message(content=message, msg_type="bot") except Exception as e: - self.logger.error(f"[Timer] Error posting message: {e}") + self.logger.error(f"[Timer] Error posting Kick message: {e}") + + if apply_twitch and hasattr(self, "command_service") and self.command_service: + try: + self.command_service.send_response(message, platform="twitch") + except Exception as e: + self.logger.error(f"[Timer] Error posting Twitch message: {e}") def _start_timers_worker(self, channel_slug: str): self._stop_worker_safely("Worker_Timers", getattr(self, 'timers_worker', None)) diff --git a/frontend/dialogs/timer_dialog.py b/frontend/dialogs/timer_dialog.py index 8d8ce67..29cdaca 100644 --- a/frontend/dialogs/timer_dialog.py +++ b/frontend/dialogs/timer_dialog.py @@ -5,7 +5,7 @@ from PySide6.QtCore import Qt, QSize from PySide6.QtGui import QColor from .base_dialog import ModernWizardPanel, ModernModal -from frontend.widgets import ModernButton, VariableTextEdit +from frontend.widgets import ModernButton, ModernSwitch, VariableTextEdit from frontend.common.theme import COLOR_RED, COLOR_GREEN from frontend.common.utils import get_icon_colored, NoWheelSlider, get_assets_path @@ -67,6 +67,38 @@ def _setup_ui(self): left_layout.addWidget(lbl_name) left_layout.addWidget(self.txt_name) + lbl_platforms = QLabel(self.i18n.get("timer.dialog.platforms_label")) + lbl_platforms.setProperty("role", "h3") + left_layout.addWidget(lbl_platforms) + + platforms_row = QWidget() + platforms_layout = QHBoxLayout(platforms_row) + platforms_layout.setContentsMargins(0, 0, 0, 0) + platforms_layout.setSpacing(16) + + kick_box = QHBoxLayout() + lbl_kick = QLabel(self.i18n.get("spam.card.platform_kick")) + lbl_kick.setProperty("role", "body") + self.switch_kick = ModernSwitch() + self.switch_kick.setChecked(True) + self.switch_kick.toggled.connect(self._update_btn_next_state) + kick_box.addWidget(lbl_kick) + kick_box.addWidget(self.switch_kick) + + twitch_box = QHBoxLayout() + lbl_twitch = QLabel(self.i18n.get("spam.card.platform_twitch")) + lbl_twitch.setProperty("role", "body") + self.switch_twitch = ModernSwitch() + self.switch_twitch.setChecked(True) + self.switch_twitch.toggled.connect(self._update_btn_next_state) + twitch_box.addWidget(lbl_twitch) + twitch_box.addWidget(self.switch_twitch) + + platforms_layout.addLayout(kick_box) + platforms_layout.addLayout(twitch_box) + platforms_layout.addStretch() + left_layout.addWidget(platforms_row) + lbl_response = QLabel(self.i18n.get("timer.dialog.response_label")) lbl_response.setProperty("role", "h3") left_layout.addWidget(lbl_response) @@ -233,6 +265,8 @@ def validate_step(self, step_index: int) -> bool: if step_index == 0: if not self.txt_name.text().strip(): return False + if not self.switch_kick.isChecked() and not self.switch_twitch.isChecked(): + return False messages = [txt.text().strip() for row, txt in self.message_rows if txt.text().strip()] if not messages: return False @@ -244,6 +278,8 @@ def validate_step(self, step_index: int) -> bool: def _load_existing(self): self.txt_name.setText(self.existing_config.get("name", "")) + self.switch_kick.setChecked(self.existing_config.get("apply_kick", True)) + self.switch_twitch.setChecked(self.existing_config.get("apply_twitch", True)) messages = self.existing_config.get("messages", []) if not messages: @@ -308,7 +344,9 @@ def get_timer_data(self): "interval_offline": interval_offline, "chat_lines": chat_lines, "keywords": keywords, - "categories": categories + "categories": categories, + "apply_kick": self.switch_kick.isChecked(), + "apply_twitch": self.switch_twitch.isChecked() } def _update_step_ui(self): @@ -338,16 +376,16 @@ def __init__(self, current_text: str, i18n, parent=None): self.set_dialog_state("accent", QColor(46, 205, 112, 60)) self.text_edit = VariableTextEdit() - self.text_edit.setPlaceholderText(self.i18n.get("timer.dialog.response_placeholder") or "Escribe tu mensaje aquí...") + self.text_edit.setPlaceholderText(self.i18n.get("timer.dialog.response_placeholder")) self.text_edit.setPlainText(current_text) self.text_edit.setMinimumHeight(150) self.text_edit.setAcceptRichText(False) self.content_layout.addWidget(self.text_edit) - btn_cancel = ModernButton(self.i18n.get("common.buttons.cancel") or "Cancelar", role="action_outlined") + btn_cancel = ModernButton(self.i18n.get("common.buttons.cancel"), role="action_outlined") btn_cancel.clicked.connect(self.reject) - self.btn_save = ModernButton(self.i18n.get("common.buttons.save") or "Guardar", role="action_accent") + self.btn_save = ModernButton(self.i18n.get("common.buttons.save"), role="action_accent") self.btn_save.clicked.connect(self.accept) self.add_action_buttons(btn_cancel, self.btn_save) diff --git a/frontend/views/chat_view.py b/frontend/views/chat_view.py index 8613750..dafcab5 100644 --- a/frontend/views/chat_view.py +++ b/frontend/views/chat_view.py @@ -1,9 +1,9 @@ # frontend\views\chat_view.py from frontend.components.chat import ChatDisplayPanel, ChatOverlaySettingsPanel, BotMutePanel, ChatTtsSettingsPanel -from PySide6.QtWidgets import QWidget, QVBoxLayout, QSizePolicy, QTabWidget +from PySide6.QtWidgets import QWidget, QVBoxLayout, QSizePolicy, QTabWidget, QBoxLayout from PySide6.QtCore import Signal -from frontend.widgets import BaseView, FlowLayout, ModernCard, ModernScrollArea +from frontend.widgets import BaseView, ModernCard, ModernScrollArea class ChatView(BaseView): volume_changed = Signal(int) @@ -19,14 +19,16 @@ class ChatView(BaseView): def __init__(self, i18n, parent=None): super().__init__(i18n=i18n, title_key="chat.header.title", subtitle_key="chat.header.subtitle", parent=parent) + self._last_body_dir = None self._setup_ui() self._connect_internal_signals() def _setup_ui(self): - self.body_layout = FlowLayout(hspacing=16, vspacing=16) + self.body_layout = QBoxLayout(QBoxLayout.Direction.LeftToRight) + self.body_layout.setSpacing(16) self.tabs = QTabWidget() - self.tabs.setMinimumWidth(380) + self.tabs.setMinimumWidth(480) self.tabs.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.tts_settings_panel = ChatTtsSettingsPanel(self.i18n) @@ -45,18 +47,35 @@ def _setup_ui(self): self.tabs.addTab(ModernScrollArea(self.overlay_settings_panel), self.i18n.get("chat.tabs.overlay")) - left_container = QWidget() - left_layout = QVBoxLayout(left_container) + self.left_container = QWidget() + left_layout = QVBoxLayout(self.left_container) left_layout.setContentsMargins(0, 0, 0, 0) left_layout.setSpacing(0) left_layout.addWidget(self.tabs) - left_container.setMinimumWidth(380) - left_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.left_container.setMinimumWidth(480) + self.left_container.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding) - self.body_layout.addWidget(left_container) - self.body_layout.addWidget(self.chat_display_panel) + self.chat_display_panel.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + + self.body_layout.addWidget(self.left_container, stretch=2) + self.body_layout.addWidget(self.chat_display_panel, stretch=3) + + self.main_layout.addLayout(self.body_layout, stretch=1) + + def resizeEvent(self, event): + super().resizeEvent(event) + width = self.width() + direction = QBoxLayout.Direction.TopToBottom if width < 1080 else QBoxLayout.Direction.LeftToRight + if self._last_body_dir != direction: + self._last_body_dir = direction + self.body_layout.setDirection(direction) - self.main_layout.addLayout(self.body_layout) + if direction == QBoxLayout.Direction.TopToBottom: + self.body_layout.setStretch(0, 1) + self.body_layout.setStretch(1, 1) + else: + self.body_layout.setStretch(0, 2) + self.body_layout.setStretch(1, 3) def _connect_internal_signals(self): self.tts_settings_panel.provider_toggled.connect(self.provider_toggled.emit) diff --git a/frontend/views/command_view.py b/frontend/views/command_view.py index 42b5e89..4a37eb5 100644 --- a/frontend/views/command_view.py +++ b/frontend/views/command_view.py @@ -218,7 +218,7 @@ def _create_permission_cell(self, cmd_data: dict) -> QWidget: layout.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) raw_perm = cmd_data.get("permission", "everyone") i18n_key = self._PERM_KEYS.get(raw_perm, "command.dialog.perm_everyone") - translated_text = self.i18n.get(i18n_key) or raw_perm + translated_text = self.i18n.get(i18n_key) tag = QFrame() tag.setFixedHeight(22) tag.setProperty("role", "badge") diff --git a/frontend/views/dashboard_view.py b/frontend/views/dashboard_view.py index 5e87f23..1f1b446 100644 --- a/frontend/views/dashboard_view.py +++ b/frontend/views/dashboard_view.py @@ -1,7 +1,7 @@ # frontend\views\dashboard_view.py import os -from PySide6.QtWidgets import (QBoxLayout, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QFrame, QGridLayout) +from PySide6.QtWidgets import (QBoxLayout, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QFrame, QGridLayout, QSizePolicy) from PySide6.QtCore import Qt, Signal, QRectF, QSize from PySide6.QtGui import QPixmap, QPainter, QColor, QPainterPath from frontend.common.theme import COLOR_BLACK, COLOR_RED, COLOR_NEUTRAL_800, COLOR_GREEN, COLOR_BLUE, COLOR_PURPLE @@ -168,6 +168,7 @@ def _setup_profile_section(self): avatar_card = ModernCard(parent=self) avatar_card.card_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + avatar_card.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) self.lbl_avatar = QLabel() self.lbl_avatar.setFixedSize(140, 140) @@ -177,6 +178,7 @@ def _setup_profile_section(self): avatar_card.addWidget(self.lbl_avatar) info_card = ModernCard(parent=self) + info_card.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) self.lbl_username = QLabel("-") self.lbl_username.setProperty("role", "h1") @@ -188,7 +190,6 @@ def _setup_profile_section(self): info_card.addWidget(self.lbl_username) info_card.addWidget(self.lbl_bio) - info_card.card_layout.addStretch() self.top_row_layout.addWidget(avatar_card) self.top_row_layout.addWidget(info_card, stretch=1) diff --git a/frontend/views/music_view.py b/frontend/views/music_view.py index d5135dc..b872ac9 100644 --- a/frontend/views/music_view.py +++ b/frontend/views/music_view.py @@ -177,7 +177,7 @@ def resizeEvent(self, event): if hasattr(self, 'stats_panel'): self.stats_panel.relayout(width) - direction = QBoxLayout.Direction.TopToBottom if width < 900 else QBoxLayout.Direction.LeftToRight + direction = QBoxLayout.Direction.TopToBottom if width < 1080 else QBoxLayout.Direction.LeftToRight if direction != self._last_direction: self._last_direction = direction if hasattr(self, 'columns_layout'): diff --git a/frontend/views/network_view.py b/frontend/views/network_view.py index 106e331..3643946 100644 --- a/frontend/views/network_view.py +++ b/frontend/views/network_view.py @@ -227,7 +227,7 @@ def paintEvent(self, event): painter.setBrush(QBrush(line_color)) painter.drawEllipse(QPointF(hover_x, y), 3, 3) - label = self.parent_graph.i18n.get(label_key) or name + label = self.parent_graph.i18n.get(label_key) tooltip_data.append((label, int(val), line_color)) max_text_w = self._base_tooltip_width @@ -385,7 +385,7 @@ def _update_labels(self): self.lbl_min.setText(f"{min_str}: {int_min} ms") self.lbl_jitter.setText(f"{jitter_str}: ±{int_jit} ms") - stab_text = self.i18n.get(f"network.graph.stability_{stab}") or stab.capitalize() + stab_text = self.i18n.get(f"network.graph.stability_{stab}") self.lbl_stability.setText(f"● {stab_text}") elif selected in self.current_latencies: curr = int(self.current_latencies.get(selected, 0)) @@ -399,7 +399,7 @@ def _update_labels(self): self.lbl_min.setText(f"{min_str}: {mn} ms") self.lbl_jitter.setText(f"{jitter_str}: ±{jit} ms") - stab_text = self.i18n.get(f"network.graph.stability_{stab}") or stab.capitalize() + stab_text = self.i18n.get(f"network.graph.stability_{stab}") self.lbl_stability.setText(f"● {stab_text}") def update_graph_data( diff --git a/frontend/views/spam_view.py b/frontend/views/spam_view.py index c5c5622..d6a5e27 100644 --- a/frontend/views/spam_view.py +++ b/frontend/views/spam_view.py @@ -69,7 +69,7 @@ def resizeEvent(self, event): super().resizeEvent(event) width = self.width() if hasattr(self, 'columns_layout'): - if width < 900: + if width < 950: self.columns_layout.setDirection(QBoxLayout.Direction.TopToBottom) self.columns_layout.setStretch(0, 0) self.columns_layout.setStretch(1, 0) diff --git a/frontend/views/timers_view.py b/frontend/views/timers_view.py index 1957eda..907becc 100644 --- a/frontend/views/timers_view.py +++ b/frontend/views/timers_view.py @@ -1,6 +1,6 @@ # frontend\views\timers_view.py -from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel, QHeaderView, QTableWidgetItem +from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel, QHeaderView, QTableWidgetItem, QFrame from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QColor from frontend.widgets import BaseView, ModernTableCard, TableActionCell @@ -20,14 +20,15 @@ def __init__(self, i18n, parent=None): def _setup_ui(self): col_1 = self.i18n.get("timer.table.col_name") col_2 = self.i18n.get("timer.table.col_message") - col_3 = self.i18n.get("timer.table.col_interval_online") - col_4 = self.i18n.get("timer.table.col_interval_offline") - col_5 = self.i18n.get("timer.table.col_chat_lines") - col_6 = self.i18n.get("timer.table.col_actions") + col_3 = self.i18n.get("timer.table.col_platforms") + col_4 = self.i18n.get("timer.table.col_interval_online") + col_5 = self.i18n.get("timer.table.col_interval_offline") + col_6 = self.i18n.get("timer.table.col_chat_lines") + col_7 = self.i18n.get("timer.table.col_actions") self.table_card = ModernTableCard( title_text=self.i18n.get("timer.header.title"), - headers=[col_1, col_2, col_3, col_4, col_5, col_6], + headers=[col_1, col_2, col_3, col_4, col_5, col_6, col_7], search_placeholder=self.i18n.get("timer.table.search_placeholder"), add_button_text=self.i18n.get("timer.table.btn_new"), add_button_icon="add.svg" @@ -53,9 +54,10 @@ def _setup_ui(self): h_header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) h_header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) h_header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents) - h_header.setSectionResizeMode(5, QHeaderView.ResizeMode.Fixed) + h_header.setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents) + h_header.setSectionResizeMode(6, QHeaderView.ResizeMode.Fixed) - self.table.setColumnWidth(5, 130) + self.table.setColumnWidth(6, 130) self.main_layout.addWidget(self.table_card, stretch=1) @@ -65,10 +67,11 @@ def populate_table(self, timers: list[dict]): for row, timer in enumerate(timers): self.table.setItem(row, 0, self._create_name_item(timer)) self.table.setItem(row, 1, self._create_message_item(timer)) - self.table.setItem(row, 2, self._create_online_item(timer)) - self.table.setItem(row, 3, self._create_offline_item(timer)) - self.table.setItem(row, 4, self._create_lines_item(timer)) - self.table.setCellWidget(row, 5, self._create_actions_cell(timer)) + self.table.setCellWidget(row, 2, self._create_platforms_cell(timer)) + self.table.setItem(row, 3, self._create_online_item(timer)) + self.table.setItem(row, 4, self._create_offline_item(timer)) + self.table.setItem(row, 5, self._create_lines_item(timer)) + self.table.setCellWidget(row, 6, self._create_actions_cell(timer)) self.table.setUpdatesEnabled(True) self.table_card.set_empty(len(timers) == 0) @@ -101,6 +104,46 @@ def _create_message_item(self, timer_data: dict) -> QTableWidgetItem: item.setToolTip(tooltip_text) return item + def _create_platforms_cell(self, timer_data: dict) -> QWidget: + container = QWidget() + layout = QHBoxLayout(container) + layout.setContentsMargins(8, 0, 8, 0) + layout.setSpacing(6) + layout.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + + apply_kick = timer_data.get("apply_kick", True) + apply_twitch = timer_data.get("apply_twitch", True) + + if not apply_kick and not apply_twitch: + lbl_none = QLabel("-") + lbl_none.setProperty("role", "body") + layout.addWidget(lbl_none) + return container + + tag = QFrame() + tag.setFixedHeight(22) + tag.setProperty("role", "badge") + + if apply_kick and apply_twitch: + tag.setProperty("state", "warning") + text = self.i18n.get("timer.table.platform_both") + elif apply_kick: + tag.setProperty("state", "everyone") + text = "Kick" + else: + tag.setProperty("state", "plugin") + text = "Twitch" + + tag_layout = QHBoxLayout(tag) + tag_layout.setContentsMargins(8, 0, 8, 0) + tag_layout.setSpacing(0) + lbl_txt = QLabel(text) + lbl_txt.setAlignment(Qt.AlignmentFlag.AlignCenter) + tag_layout.addWidget(lbl_txt) + layout.addWidget(tag) + + return container + def _create_online_item(self, timer_data: dict) -> QTableWidgetItem: online = timer_data.get("interval_online") unit_min = self.i18n.get("timer.table.unit_minutes") diff --git a/locales/en.json b/locales/en.json index 8b7b3c0..df40c12 100644 --- a/locales/en.json +++ b/locales/en.json @@ -866,6 +866,8 @@ "name_placeholder": "e.g., Social Links", "offline_interval_label": "Offline interval (minutes)", "online_interval_label": "Online interval (minutes)", + "platforms_desc": "Activate platforms where this timer will post.", + "platforms_label": "Active Platform(s):", "response_label": "Response messages (one will be chosen randomly):", "response_placeholder": "Enter a message...", "subtitle": "Define a new automatic timer to send messages to the chat.", @@ -899,6 +901,8 @@ "col_interval_online": "Interval Online", "col_message": "Message", "col_name": "Name", + "col_platforms": "Platforms", + "platform_both": "Both", "search_placeholder": "Search timer...", "tooltip_delete": "Delete timer permanently", "tooltip_edit": "Edit timer", diff --git a/locales/es.json b/locales/es.json index 8953b00..01d7940 100644 --- a/locales/es.json +++ b/locales/es.json @@ -866,6 +866,8 @@ "name_placeholder": "Ej: Redes Sociales", "offline_interval_label": "Intervalo Offline (minutos)", "online_interval_label": "Intervalo Online (minutos)", + "platforms_desc": "Activa las plataformas en las que se emitirá este temporizador.", + "platforms_label": "Plataforma(s) activas:", "response_label": "Mensajes de respuesta (se elegirá uno aleatoriamente):", "response_placeholder": "Escribe el mensaje de respuesta aquí...", "subtitle": "Define un nuevo temporizador automático para enviar mensajes al chat.", @@ -899,6 +901,8 @@ "col_interval_online": "Int. Online", "col_message": "Mensaje", "col_name": "Nombre", + "col_platforms": "Plataformas", + "platform_both": "Ambos", "search_placeholder": "Buscar temporizador...", "tooltip_delete": "Eliminar temporizador permanentemente", "tooltip_edit": "Editar temporizador", diff --git a/tests/test_timer_service.py b/tests/test_timer_service.py new file mode 100644 index 0000000..d482db4 --- /dev/null +++ b/tests/test_timer_service.py @@ -0,0 +1,68 @@ +# tests/test_timer_service.py + +import os +import pytest +from backend.database.manager import DatabaseManager +from backend.database.timers_storage import SQLiteTimersStorage +from backend.services.chat.timer_service import TimerService + +@pytest.fixture +def db_manager(tmp_path): + db_file = os.path.join(tmp_path, "test_minikick.db") + db = DatabaseManager(db_name=db_file) + yield db + +@pytest.fixture +def timer_service(db_manager): + storage = SQLiteTimersStorage(db_manager) + return TimerService(storage) + +def test_save_and_load_timer_platforms(timer_service): + timer_service.save_timer( + name="Socials", + messages=["Follow my twitter!"], + is_active=True, + interval_online=5, + interval_offline=30, + chat_lines=2, + keywords=[], + categories=[], + apply_kick=True, + apply_twitch=False + ) + + timers = timer_service.get_all_timers() + assert len(timers) == 1 + t = timers[0] + assert t["name"] == "Socials" + assert t["apply_kick"] is True + assert t["apply_twitch"] is False + +def test_check_timers_returns_platform_tuple(timer_service): + timer_service.save_timer( + name="Discord", + messages=["Join Discord!"], + is_active=True, + interval_online=1, + interval_offline=1, + chat_lines=0, + keywords=[], + categories=[], + apply_kick=False, + apply_twitch=True + ) + + timers = timer_service.get_all_timers() + timer_id = timers[0]["id"] + timer_service.tracking_state[timer_id] = { + "last_posted_time": 0, + "chat_lines": 0, + "message_index": 0 + } + + res = timer_service.check_timers({"is_live": True, "title": "", "category": ""}) + assert len(res) == 1 + msg, apply_kick, apply_twitch = res[0] + assert msg == "Join Discord!" + assert apply_kick is False + assert apply_twitch is True diff --git a/walkthroughs/v1.5.0/Release_Notes_v1.5.0.md b/walkthroughs/v1.5.0/Release_Notes_v1.5.0.md index 17d0758..5acbe73 100644 --- a/walkthroughs/v1.5.0/Release_Notes_v1.5.0.md +++ b/walkthroughs/v1.5.0/Release_Notes_v1.5.0.md @@ -18,6 +18,10 @@ ### 4. Reproducción Continua en TTS Local (SAPI5 / Windows) - **Instanciación Segura por Mensaje**: Refactorización de `LocalTTSProvider` para inicializar y limpiar la pila COM (`pythoncom`) y el motor `pyttsx3` por cada mensaje entrante. +### 5. Timers Multi-Plataforma (Switches Kick/Twitch y Tags en Tabla) +- **Selección de Plataforma por Timer**: Inclusión de switches independientes en `TimerConfigWizard` para activar o desactivar cada temporizador por canal (Kick, Twitch o Ambos). +- **Insignias en Tabla e Integración i18n**: Visualización de tags estilizadas (`[Kick]` en verde y `[Twitch]` en morado) en la columna Plataformas de `TimersView`. Enrutamiento automatizado con `CommandService`. + --- ## Métricas de Calidad @@ -26,4 +30,5 @@ | :--- | :--- | :--- | :--- | | Mensajes del Bot en Kick | Aparecían dos veces (Duplicados) | **Entrada Única desde WebSocket** | Chat de Kick limpio sin duplicaciones | | Orden Cronológico en UI | La respuesta del bot aparecía antes del comando | **Orden Estricto (Comando -> Respuesta)** | Línea de tiempo de chat 100% natural | -| Cobertura de Pruebas Unitarias | 30 pruebas pasando | **31 pruebas pasando** en 7.58s | Cobertura total de pipeline y ejecutores | +| Timers de Chat | Únicamente emisión global a Kick | **Switches Kick/Twitch + Insignias en Tabla** | Control total multi-plataforma por temporizador | +| Cobertura de Pruebas Unitarias | 15 pruebas pasando | **17 pruebas pasando** en 0.76s | Cobertura total de pipeline, timers y ejecutores | diff --git a/walkthroughs/v1.5.0/WT-1.5.0_02.md b/walkthroughs/v1.5.0/WT-1.5.0_02.md index e6b8288..8be6ff4 100644 --- a/walkthroughs/v1.5.0/WT-1.5.0_02.md +++ b/walkthroughs/v1.5.0/WT-1.5.0_02.md @@ -103,8 +103,40 @@ Documento consolidado de la versión **v1.5.0** de MiniKick. Resume la totalidad --- -## 6. Pruebas Automatizadas (Pytest) +## 8. Timers Multi-Plataforma (Switches Kick/Twitch y Tags en Tabla) +### 8.1. Interfaz del Diálogo (`frontend/dialogs/timer_dialog.py`) +- **[timer_dialog.py](file:///c:/Users/TheAn/Desktop/python/Kick/frontend/dialogs/timer_dialog.py):** + - Añadida la sección de activación de plataforma en `TimerConfigWizard` con dos switches `ModernSwitch` (`switch_kick` y `switch_twitch`). + - `_load_existing()` e `get_timer_data()` cargan y persisten los campos `apply_kick` y `apply_twitch`. + +### 8.2. Persistencia y Migración de Base de Datos (`backend/database/`) +- **[manager.py](file:///c:/Users/TheAn/Desktop/python/Kick/backend/database/manager.py):** + - Incorporadas las columnas `apply_kick INTEGER DEFAULT 1` y `apply_twitch INTEGER DEFAULT 1` en la tabla `chat_timers`. + - Migración automática integrada en `_upgrade_schema()` mediante `ALTER TABLE`. +- **[timers_storage.py](file:///c:/Users/TheAn/Desktop/python/Kick/backend/database/timers_storage.py):** + - `load_all()`, `get_timer_by_id()`, `save_timer()` y `search_timers()` actualizados para leer y escribir los flags de plataforma. + +### 8.3. Vista de Tabla e Insignias Stylized (`frontend/views/timers_view.py` & i18n) +- **[timers_view.py](file:///c:/Users/TheAn/Desktop/python/Kick/frontend/views/timers_view.py):** + - Añadida la columna **Plataformas** (`timer.table.col_platforms`). + - Renderizado de badges/tags con `QFrame` (`[Kick]` en verde y `[Twitch]` en morado) según el estado activo de cada temporizador. +- **Traducciones ([es.json](file:///c:/Users/TheAn/Desktop/python/Kick/locales/es.json) / [en.json](file:///c:/Users/TheAn/Desktop/python/Kick/en.json)):** + - Añadidas las claves i18n: `timer.table.col_platforms`, `timer.dialog.platforms_label` y `timer.dialog.platforms_desc`. Zero hardcoded UI text. + +### 8.4. Enrutamiento y Ejecución (`backend/services/chat/timer_service.py` & `main_window_core.py`) +- **[timer_service.py](file:///c:/Users/TheAn/Desktop/python/Kick/backend/services/chat/timer_service.py):** + - `check_timers()` retorna tuplas `(message, apply_kick, apply_twitch)`. +- **[timers_worker.py](file:///c:/Users/TheAn/Desktop/python/Kick/backend/workers/timers_worker.py):** + - Emite `post_message_requested(msg, apply_kick, apply_twitch)`. +- **[main_window_core.py](file:///c:/Users/TheAn/Desktop/python/Kick/frontend/core/main_window_core.py):** + - `_send_timer_message` enruta los mensajes a través de `CommandService.send_response(message, platform="kick" / "twitch")`, permitiendo su visualización uniforme en el chat y overlay. + +--- + +## 9. Pruebas Automatizadas (Pytest) + +- **[test_timer_service.py](file:///c:/Users/TheAn/Desktop/python/Kick/tests/test_timer_service.py)**: Pruebas de persistencia y enrutamiento por plataforma para temporizadores. - **[test_spam_service.py](file:///c:/Users/TheAn/Desktop/python/Kick/tests/test_spam_service.py)**: Pruebas para filtrado específico por plataforma e inmunidad de emotes en mayúsculas. - **[test_twitch_auth.py](file:///c:/Users/TheAn/Desktop/python/Kick/tests/test_twitch_auth.py)**: Pruebas de verificación de scopes faltantes de Twitch. - **[test_tts_local.py](file:///c:/Users/TheAn/Desktop/python/Kick/tests/test_tts_local.py)**: Prueba de habla continua en `LocalTTSProvider`. @@ -115,5 +147,5 @@ Documento consolidado de la versión **v1.5.0** de MiniKick. Resume la totalidad uv run pytest ``` ```text -============================= 31 passed in 7.58s ============================== +============================= 17 passed in 0.76s ============================== ``` diff --git a/walkthroughs/v1.5.0/WT-1.5.0_03.md b/walkthroughs/v1.5.0/WT-1.5.0_03.md new file mode 100644 index 0000000..908e1e8 --- /dev/null +++ b/walkthroughs/v1.5.0/WT-1.5.0_03.md @@ -0,0 +1,34 @@ +# Walkthrough: Weighted Score Music Cache Eviction & Retention Fixes + +## Overview +Implemented an intelligent, weighted-scoring cache eviction algorithm for MiniKick's 5GB music cache. Also resolved underlying bugs causing premature track deletion before reaching the 5GB cache threshold. + +--- + +## Key Changes + +### Fixes for Premature Track Deletion +- **[youtube_client.py](file:///c:/Users/TheAn/Desktop/python/Kick/backend/providers/music/youtube_client.py#L252)**: Removed `os.remove(self.current_local_file)` from `shutdown()`. Active tracks playing when closing MiniKick will now remain safely in cache. +- **[manager.py](file:///c:/Users/TheAn/Desktop/python/Kick/backend/database/manager.py#L298)**: Removed the 15-day automatic SQLite trigger `prune_youtube_cache`. Metadata entries are no longer purged arbitrarily after 15 days, allowing the weighted score algorithm to fully govern retention up to 5GB. + +### Database Layer +- **[manager.py](file:///c:/Users/TheAn/Desktop/python/Kick/backend/database/manager.py)**: Added `file_size_mb REAL DEFAULT 4.0` column definition and safe migration (`ALTER TABLE`) to `youtube_search_cache`. +- **[music_storage.py](file:///c:/Users/TheAn/Desktop/python/Kick/backend/database/music_storage.py)**: + - Added `update_file_size(query_or_url, size_mb)` to save actual file sizes to SQLite. + - Refactored `get_least_popular_cached_songs()` to query candidates using the SQLite formula: + $$\text{Score} = \frac{\text{play\_count}}{((\text{julianday('now')} - \text{julianday(last\_accessed)}) + 0.5) \times \text{file\_size\_mb}}$$ + Candidates are returned in ascending order of `score`, ensuring low-value files are evicted first. + +### Provider Layer +- **[youtube_client.py](file:///c:/Users/TheAn/Desktop/python/Kick/backend/providers/music/youtube_client.py)**: Added file size measurement (`os.path.getsize()`) on active track resolution (`_on_song_resolved`) and background preloading (`on_preload_resolved`), storing exact sizes in SQLite. + +--- + +## Verification & Automated Test Results + +### Test Suite Execution +Executed unit test suite via `uv run pytest`: +```bash +33 passed in 7.76s +``` +All tests passed successfully. diff --git a/walkthroughs/v1.5.0/WT-1.5.0_04.md b/walkthroughs/v1.5.0/WT-1.5.0_04.md new file mode 100644 index 0000000..a63bb3c --- /dev/null +++ b/walkthroughs/v1.5.0/WT-1.5.0_04.md @@ -0,0 +1,25 @@ +# Walkthrough WT-1.5.0_04: Dashboard & Chat View Layout Adjustments + +## Summary +Fixed layout stretching issues in `DashboardView`, implemented vertical layout for voice selection setting rows in `ChatTtsSettingsPanel`, and optimized stretch proportions in `ChatView`. + +## Key Changes + +### 1. Dashboard View ([dashboard_view.py](file:///c:/Users/TheAn/Desktop/python/Kick/frontend/views/dashboard_view.py)) +- Configured vertical size policy on `avatar_card` and `info_card` to `QSizePolicy.Policy.Preferred`. +- Removed `info_card.card_layout.addStretch()`. +- **Result**: Cards fit content cleanly without vertical stretching. + +### 2. Chat View ([chat_view.py](file:///c:/Users/TheAn/Desktop/python/Kick/frontend/views/chat_view.py)) +- Implemented dynamic stretch factors based on layout orientation: + - **Horizontal Mode**: `setStretch(0, 2)` (tabs ~40%) and `setStretch(1, 3)` (chat ~60%). + - **Vertical Mode**: `setStretch(0, 1)` (tabs 50% height) and `setStretch(1, 1)` (chat 50% height). +- **Result**: Equal 50/50 vertical division in portrait mode, and clean 40/60 horizontal division in wide mode. + +### 3. TTS Settings Panel ([tts_settings.py](file:///c:/Users/TheAn/Desktop/python/Kick/frontend/components/chat/tts_settings.py)) +- Implemented `VoiceSettingRow` widget to stack title/description above the combo box & test button. +- **Result**: Descriptions span the full width of the card (eliminating narrow text wrapping), and combo boxes take full width for long voice labels. + +## Big-O & Architectural Impact +- **Architecture**: Enforces Separation of Responsibilities in UI layout without modifying business logic or models. +- **Big-O**: Layout calculation remains $O(1)$ per resize event pass. From e5fd7190f0ea47296f90dfd2e676c160410a0ec8 Mon Sep 17 00:00:00 2001 From: Andro2K Date: Wed, 12 Aug 2026 12:42:14 -0500 Subject: [PATCH 3/5] feat: implement rewards system, new UI components, and supporting assets for v1.5.0 --- assets/icons/illustration-document.svg | 3 + assets/icons/illustration-earphone.svg | 3 + assets/icons/illustration-menu.svg | 3 + assets/icons/illustration-picture.svg | 3 + assets/icons/illustration-switch.svg | 3 + assets/icons/illustration-thumbs-up.svg | 3 + assets/icons/illustration-time.svg | 3 + assets/icons/illustration_add-files.svg | 1 - assets/icons/illustration_app-running.svg | 1 - assets/icons/illustration_clock.svg | 1 - assets/icons/illustration_file-search.svg | 1 - assets/icons/illustration_image-files.svg | 1 - assets/icons/illustration_music.svg | 1 - assets/icons/illustration_welcome.svg | 1 - assets/web/auth.html | 525 +++++++++--------- backend/config/default_en_locale.py | 27 +- backend/controllers/rewards_controller.py | 9 + backend/database/manager.py | 6 +- backend/database/rewards_storage.py | 20 +- backend/services/rewards/rewards_service.py | 19 +- backend/services/rewards/thumbnail_service.py | 68 +++ frontend/components/music/queue_panel.py | 2 +- frontend/dialogs/already_running_dialog.py | 30 +- frontend/views/command_view.py | 7 +- frontend/views/dashboard_view.py | 2 +- frontend/views/log_view.py | 8 +- frontend/views/rewards_view.py | 116 +++- frontend/views/timers_view.py | 7 +- frontend/widgets/table.py | 6 +- locales/en.json | 3 + locales/es.json | 3 + main.py | 8 + walkthroughs/v1.5.0/WT-1.5.0_05.md | 27 + walkthroughs/v1.5.0/WT-1.5.0_06.md | 19 + walkthroughs/v1.5.0/WT-1.5.0_07.md | 19 + walkthroughs/v1.5.0/WT-1.5.0_08.md | 26 + walkthroughs/v1.5.0/WT-1.5.0_09.md | 24 + walkthroughs/v1.5.0/WT-1.5.0_10.md | 26 + walkthroughs/v1.5.0/WT-1.5.0_11.md | 19 + walkthroughs/v1.5.0/WT-1.5.0_12.md | 16 + 40 files changed, 765 insertions(+), 305 deletions(-) create mode 100644 assets/icons/illustration-document.svg create mode 100644 assets/icons/illustration-earphone.svg create mode 100644 assets/icons/illustration-menu.svg create mode 100644 assets/icons/illustration-picture.svg create mode 100644 assets/icons/illustration-switch.svg create mode 100644 assets/icons/illustration-thumbs-up.svg create mode 100644 assets/icons/illustration-time.svg delete mode 100644 assets/icons/illustration_add-files.svg delete mode 100644 assets/icons/illustration_app-running.svg delete mode 100644 assets/icons/illustration_clock.svg delete mode 100644 assets/icons/illustration_file-search.svg delete mode 100644 assets/icons/illustration_image-files.svg delete mode 100644 assets/icons/illustration_music.svg delete mode 100644 assets/icons/illustration_welcome.svg create mode 100644 backend/services/rewards/thumbnail_service.py create mode 100644 walkthroughs/v1.5.0/WT-1.5.0_05.md create mode 100644 walkthroughs/v1.5.0/WT-1.5.0_06.md create mode 100644 walkthroughs/v1.5.0/WT-1.5.0_07.md create mode 100644 walkthroughs/v1.5.0/WT-1.5.0_08.md create mode 100644 walkthroughs/v1.5.0/WT-1.5.0_09.md create mode 100644 walkthroughs/v1.5.0/WT-1.5.0_10.md create mode 100644 walkthroughs/v1.5.0/WT-1.5.0_11.md create mode 100644 walkthroughs/v1.5.0/WT-1.5.0_12.md diff --git a/assets/icons/illustration-document.svg b/assets/icons/illustration-document.svg new file mode 100644 index 0000000..b2c2cf1 --- /dev/null +++ b/assets/icons/illustration-document.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/icons/illustration-earphone.svg b/assets/icons/illustration-earphone.svg new file mode 100644 index 0000000..2be7a0d --- /dev/null +++ b/assets/icons/illustration-earphone.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/icons/illustration-menu.svg b/assets/icons/illustration-menu.svg new file mode 100644 index 0000000..99b5c3e --- /dev/null +++ b/assets/icons/illustration-menu.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/icons/illustration-picture.svg b/assets/icons/illustration-picture.svg new file mode 100644 index 0000000..5e6de67 --- /dev/null +++ b/assets/icons/illustration-picture.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/icons/illustration-switch.svg b/assets/icons/illustration-switch.svg new file mode 100644 index 0000000..ccbd869 --- /dev/null +++ b/assets/icons/illustration-switch.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/icons/illustration-thumbs-up.svg b/assets/icons/illustration-thumbs-up.svg new file mode 100644 index 0000000..77f7cb7 --- /dev/null +++ b/assets/icons/illustration-thumbs-up.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/icons/illustration-time.svg b/assets/icons/illustration-time.svg new file mode 100644 index 0000000..6a16ae6 --- /dev/null +++ b/assets/icons/illustration-time.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/icons/illustration_add-files.svg b/assets/icons/illustration_add-files.svg deleted file mode 100644 index dfb4eac..0000000 --- a/assets/icons/illustration_add-files.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/illustration_app-running.svg b/assets/icons/illustration_app-running.svg deleted file mode 100644 index 8fc7f3f..0000000 --- a/assets/icons/illustration_app-running.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/illustration_clock.svg b/assets/icons/illustration_clock.svg deleted file mode 100644 index e64f4cb..0000000 --- a/assets/icons/illustration_clock.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/illustration_file-search.svg b/assets/icons/illustration_file-search.svg deleted file mode 100644 index d1093df..0000000 --- a/assets/icons/illustration_file-search.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/illustration_image-files.svg b/assets/icons/illustration_image-files.svg deleted file mode 100644 index 53d308b..0000000 --- a/assets/icons/illustration_image-files.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/illustration_music.svg b/assets/icons/illustration_music.svg deleted file mode 100644 index 9c5ac7d..0000000 --- a/assets/icons/illustration_music.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/icons/illustration_welcome.svg b/assets/icons/illustration_welcome.svg deleted file mode 100644 index 3fba6da..0000000 --- a/assets/icons/illustration_welcome.svg +++ /dev/null @@ -1 +0,0 @@ -welcome_cats \ No newline at end of file diff --git a/assets/web/auth.html b/assets/web/auth.html index 0bffeeb..e0c5d52 100644 --- a/assets/web/auth.html +++ b/assets/web/auth.html @@ -1,284 +1,287 @@ - - - - Autenticación - MiniKick - + + + +