From cd21244f12d71b1f90832cf9ae1c4131c9958193 Mon Sep 17 00:00:00 2001 From: Afdaan Date: Wed, 18 Mar 2026 21:15:33 +0700 Subject: [PATCH 01/14] feat: add voice_helpers and integrate !tts handling --- handlers/conversation.py | 70 ++++++++++++++++++++++++++------- handlers/voice.py | 39 +++++------------- utils/voice_helpers.py | 85 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 44 deletions(-) create mode 100644 utils/voice_helpers.py diff --git a/handlers/conversation.py b/handlers/conversation.py index 6c4e6ea..4fcb2e0 100644 --- a/handlers/conversation.py +++ b/handlers/conversation.py @@ -12,7 +12,7 @@ from telegram import Update from telegram.constants import ChatAction -from telegram.ext import ContextTypes, MessageHandler, filters +from telegram.ext import CommandHandler, ContextTypes, MessageHandler, filters from config.settings import ( COMMAND_PREFIX, @@ -54,20 +54,21 @@ def __init__( self.nlp = nlp_engine or NLPEngine() def get_handlers(self) -> List: + """Returns the list of message handlers for conversation and internal TTS logic.""" handlers = [ MessageHandler( filters.TEXT & filters.ChatType.PRIVATE & ~filters.COMMAND - & ~filters.Regex(r"^!(?!ai)"), # Ignore ! commands except !ai + & ~filters.Regex(r"^!(?!ai|tts)"), # Ignore ! commands except !ai and !tts self.chat_command ), MessageHandler( ( filters.TEXT & filters.ChatType.GROUPS & ~filters.COMMAND & - ~filters.Regex(r"^!(?!ai)") & # Ignore ! commands except !ai (same as private) + ~filters.Regex(r"^!(?!ai|tts)") & # Ignore ! commands except !ai and !tts (same as private) ( - filters.Regex(f"^{COMMAND_PREFIX}") | + filters.Regex(f"^({COMMAND_PREFIX}|!tts|/tts)") | filters.REPLY ) ), @@ -106,20 +107,46 @@ async def chat_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) reply_context = "" is_reply_to_alya = False replied_message_is_conversation = False + requires_tts = False + + # 1. Parse Special Command Prefixes + if message_text.lower().startswith("!tts"): + requires_tts = True + message_text = message_text[4:].strip() + elif message_text.lower().startswith("/tts"): + bot_me = await context.bot.get_me() if hasattr(context.bot, "get_me") else None + bot_username = bot_me.username if bot_me else None + + fullname = f"/tts@{bot_username.lower()}" if bot_username else None + if fullname and message_text.lower().startswith(fullname): + message_text = message_text[len(fullname):].strip() + else: + message_text = message_text[4:].strip() + requires_tts = True + + # 2. Extract Reply Context if update.message.reply_to_message: replied = update.message.reply_to_message - if replied.from_user and replied.from_user.is_bot: - if replied.from_user.id == context.bot.id: - reply_context = replied.text or "" - is_reply_to_alya = True - if replied.text and replied.text.endswith("\u200C"): - replied_message_is_conversation = True + if replied.from_user and replied.from_user.id == context.bot.id: + reply_context = replied.text or "" + is_reply_to_alya = True + # Check for hidden zero-width char used to distinguish AI conversation vs other system msgs + if replied.text and replied.text.endswith("\u200C"): + replied_message_is_conversation = True + elif requires_tts: + # User used !tts to reply to a person; include their message as context + reply_context = f"{replied.from_user.first_name} said: {replied.text or 'Media'}" + is_reply_to_alya = False + # 3. Handle Group vs Private Filtering if update.message.chat.type in ["group", "supergroup"]: if is_reply_to_alya: + # Filter replies to Alya to ensure we only respond when it's part of a conversation if update.message.reply_to_message and not replied_message_is_conversation: return query = message_text.strip() + elif requires_tts: + query = message_text.strip() else: if message_text.startswith(COMMAND_PREFIX): query = message_text.replace(COMMAND_PREFIX, "", 1).strip() @@ -128,8 +155,9 @@ async def chat_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) else: query = message_text.strip() + # 4. Final Basic Validation bot_username = (await context.bot.get_me()).username if hasattr(context.bot, "get_me") else None - if query.startswith("/"): + if query.startswith("/") and not requires_tts: if bot_username: if query.split()[0].lower().startswith(f"/") and f"@{bot_username.lower()}" in query.split()[0].lower(): return @@ -245,7 +273,7 @@ async def chat_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) if response: logger.info(f"[RESPONSE_RECEIVED] Got response from Gemini, length={len(response)}") - await self._process_and_send_response(update, user, response, user_context["message_context"], lang, loading_msg=loading_msg) + await self._process_and_send_response(update, context, user, response, user_context["message_context"], lang, loading_msg=loading_msg, requires_tts=requires_tts) else: logger.warning(f"[RESPONSE_RECEIVED] Response is empty or None") await self._send_error_response(update, user.first_name, lang, loading_msg=loading_msg) @@ -506,11 +534,13 @@ def extract_emoji(text: str) -> tuple[str, str]: async def _process_and_send_response( self, update: Update, + context: ContextTypes.DEFAULT_TYPE, user, response: str, message_context: Dict[str, Any], lang: str, - loading_msg: Optional[Any] = None + loading_msg: Optional[Any] = None, + requires_tts: bool = False ) -> None: """Clean, format, and send response to Telegram.""" try: @@ -533,8 +563,20 @@ async def _process_and_send_response( except Exception as e: logger.error(f"Failed to edit message: {e}") await update.message.reply_html(formatted_response) - else: await update.message.reply_html(formatted_response) + + if requires_tts: + from utils.voice_helpers import send_voice_reply + + voice_processor = context.application.bot_data.get("voice_processor") + await send_voice_reply( + update=update, + context=context, + text=response, + voice_processor=voice_processor, + db_manager=self.db, + source_lang=lang + ) except Exception as e: logger.error(f"Error processing response: {e}", exc_info=True) await self._send_error_response(update, user.first_name, lang, loading_msg=loading_msg) diff --git a/handlers/voice.py b/handlers/voice.py index 1a5b622..02f8f8d 100644 --- a/handlers/voice.py +++ b/handlers/voice.py @@ -2,6 +2,7 @@ Voice message handler for Alya Bot. Handles voice message input and generates voice responses using the Alya voice model. """ +import asyncio import logging import os import tempfile @@ -19,10 +20,8 @@ from core.nlp import NLPEngine, ContextManager from database.database_manager import DatabaseManager, db_manager, get_user_lang from utils.voice_processor import VoiceProcessor -from utils.language_translator import translate_response_for_voice -from utils.tts_queue import dispatch_tts -from utils.telegram_helpers import ChatActionSender -from utils.formatters import format_persona_response +from utils.voice_helpers import send_voice_reply +from utils.telegram_helpers import ChatActionSender, start_loading_animation from config.settings import VOICE_ENABLED, DEFAULT_LANGUAGE, ADMIN_IDS, AFFECTION_POINTS logger = logging.getLogger(__name__) @@ -124,7 +123,6 @@ async def handle_voice_message(self, update: Update, context: ContextTypes.DEFAU lang_flag = {"en": "🇺🇸", "id": "🇮🇩", "jp": "🎌"}.get(detected_lang, "🌐") await update.message.reply_html(f"🎤 ({lang_flag} {detected_lang.upper()}): {user_text}") - import asyncio phrase = "Alya is thinking" if db_user_dict.get('language_code', DEFAULT_LANGUAGE) == 'en' else "Alya lagi mikir" loading_msg = await update.message.reply_text(f"
💭 {phrase}...
", parse_mode="HTML") @@ -193,33 +191,14 @@ async def handle_voice_message(self, update: Update, context: ContextTypes.DEFAU except Exception: await update.message.reply_html(ui_text) - voice_lang = self.db_manager.get_user_voice_language(user.id) if self.db_manager else "en" source_lang = db_user_dict.get('language_code', DEFAULT_LANGUAGE) - - # Extract dialogue and translate to voice_lang before sending to TTS - tts_text = await translate_response_for_voice(response, source_lang, voice_lang) - - import asyncio - tts_phrase = "Alya is recording a voice note" if source_lang == 'en' else "Alya lagi ngerekam voice note" - tts_loading_msg = await update.message.reply_text(f"
🎙️ {tts_phrase}...
", parse_mode="HTML") - - from utils.telegram_helpers import start_loading_animation - tts_loading_task = start_loading_animation( - tts_loading_msg, - tts_phrase, - frames=["🎙️", "🎶", "✨"], - interval=1.2 - ) - - await dispatch_tts( - bot=context.bot, - chat_id=update.effective_chat.id, - reply_to_message_id=update.message.message_id, + await send_voice_reply( + update=update, + context=context, + text=response, voice_processor=self.voice_processor, - response_text=tts_text, - voice_lang=voice_lang, - user_lang=source_lang, - loading_message_id=tts_loading_msg.message_id + db_manager=self.db_manager, + source_lang=source_lang ) # 5. Metadata Update diff --git a/utils/voice_helpers.py b/utils/voice_helpers.py new file mode 100644 index 0000000..af53ae3 --- /dev/null +++ b/utils/voice_helpers.py @@ -0,0 +1,85 @@ +""" +Helper utilities for handling voice-related bot responses. +Reduces duplication between VoiceHandler and ConversationHandler. +""" +import logging +import asyncio +from typing import Any, Optional + +from telegram import Update +from telegram.ext import ContextTypes + +from utils.tts_queue import dispatch_tts +from utils.language_translator import translate_response_for_voice +from utils.telegram_helpers import start_loading_animation +from config.settings import DEFAULT_LANGUAGE + +logger = logging.getLogger(__name__) + +async def send_voice_reply( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + text: str, + voice_processor: Any, + db_manager: Any, + source_lang: Optional[str] = None +) -> None: + """ + Translates text dialogue, shows a recording animation, and dispatches a TTS job. + + Args: + update: The Telegram update. + context: The Telegram context. + text: The raw AI response text to convert to voice. + voice_processor: The VoiceProcessor instance. + db_manager: The database manager to get user language settings. + source_lang: The language the text is currently in. + """ + if not voice_processor: + logger.warning("Attempted to send voice reply but voice_processor is not available") + return + + user = update.effective_user + chat_id = update.effective_chat.id + + if source_lang is None: + source_lang = DEFAULT_LANGUAGE + + try: + # 1. Get user's preferred voice language + voice_lang = db_manager.get_user_voice_language(user.id) if db_manager else "en" + + # 2. Extract dialogue and translate for TTS + tts_text = await translate_response_for_voice(text, source_lang, voice_lang) + + # 3. Create loading/recording message + tts_phrase = "Alya is recording a voice note" if source_lang == 'en' else "Alya lagi ngerekam voice note" + tts_loading_msg = await update.message.reply_html( + f"
🎙️ {tts_phrase}...
" + ) + + # 4. Start animation + tts_loading_task = start_loading_animation( + tts_loading_msg, + tts_phrase, + frames=["🎙️", "🎶", "✨"], + interval=1.2 + ) + + try: + # 5. Dispatch job to TTS microservice (fire and forget) + await dispatch_tts( + bot=context.bot, + chat_id=chat_id, + reply_to_message_id=update.message.message_id, + voice_processor=voice_processor, + response_text=tts_text, + voice_lang=voice_lang, + user_lang=source_lang, + loading_message_id=tts_loading_msg.message_id + ) + finally: + pass + + except Exception as e: + logger.error(f"Error in send_voice_reply: {e}", exc_info=True) From 299c76416457ad5d3a68ed525026e3cedb148d27 Mon Sep 17 00:00:00 2001 From: Afdaan Date: Thu, 19 Mar 2026 01:18:12 +0700 Subject: [PATCH 02/14] fix: remove send_voice_reply call from VoiceHandler --- handlers/voice.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/handlers/voice.py b/handlers/voice.py index 02f8f8d..35a305c 100644 --- a/handlers/voice.py +++ b/handlers/voice.py @@ -20,8 +20,8 @@ from core.nlp import NLPEngine, ContextManager from database.database_manager import DatabaseManager, db_manager, get_user_lang from utils.voice_processor import VoiceProcessor -from utils.voice_helpers import send_voice_reply from utils.telegram_helpers import ChatActionSender, start_loading_animation +from utils.formatters import format_persona_response from config.settings import VOICE_ENABLED, DEFAULT_LANGUAGE, ADMIN_IDS, AFFECTION_POINTS logger = logging.getLogger(__name__) @@ -191,15 +191,6 @@ async def handle_voice_message(self, update: Update, context: ContextTypes.DEFAU except Exception: await update.message.reply_html(ui_text) - source_lang = db_user_dict.get('language_code', DEFAULT_LANGUAGE) - await send_voice_reply( - update=update, - context=context, - text=response, - voice_processor=self.voice_processor, - db_manager=self.db_manager, - source_lang=source_lang - ) # 5. Metadata Update if self.memory_manager: From f190821fcba16dd4f5d1ead2b4bbdc8f90792cdc Mon Sep 17 00:00:00 2001 From: Afdaan Date: Thu, 19 Mar 2026 01:29:08 +0700 Subject: [PATCH 03/14] fix: handle replied voice notes & group TTS triggers --- handlers/conversation.py | 36 ++++++++++++++++++++++++++++++++---- handlers/voice.py | 33 +++++++++++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/handlers/conversation.py b/handlers/conversation.py index 4fcb2e0..1128655 100644 --- a/handlers/conversation.py +++ b/handlers/conversation.py @@ -7,6 +7,7 @@ import asyncio import re import os +import tempfile import unicodedata import langdetect @@ -133,9 +134,16 @@ async def chat_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) # Check for hidden zero-width char used to distinguish AI conversation vs other system msgs if replied.text and replied.text.endswith("\u200C"): replied_message_is_conversation = True - elif requires_tts: - # User used !tts to reply to a person; include their message as context - reply_context = f"{replied.from_user.first_name} said: {replied.text or 'Media'}" + else: + if replied.voice: + reply_context = await self._extract_replied_voice_context( + replied_msg=replied, + context=context, + chat_id=update.effective_chat.id, + lang=lang + ) + else: + reply_context = f"{replied.from_user.first_name} said: {replied.text or 'Media'}" is_reply_to_alya = False # 3. Handle Group vs Private Filtering @@ -580,7 +588,27 @@ async def _process_and_send_response( except Exception as e: logger.error(f"Error processing response: {e}", exc_info=True) await self._send_error_response(update, user.first_name, lang, loading_msg=loading_msg) - + + async def _extract_replied_voice_context(self, replied_msg: Any, context: ContextTypes.DEFAULT_TYPE, chat_id: int, lang: str) -> str: + """Helper to download and transcribe a replied-to voice note for conversation context.""" + voice_processor = context.application.bot_data.get("voice_processor") + if not voice_processor: + return f"{replied_msg.from_user.first_name} sent a voice note." + + try: + await context.bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING) + file = await context.bot.get_file(replied_msg.voice.file_id) + with tempfile.TemporaryDirectory() as tmp_dir: + ogg_path = os.path.join(tmp_dir, f"reply_voice_{replied_msg.voice.file_id}.ogg") + await file.download_to_drive(ogg_path) + transcription_data = await voice_processor.transcribe_audio(ogg_path, lang=lang) + if transcription_data: + return f"{replied_msg.from_user.first_name} said (Voice Note): {transcription_data[0]}" + return f"{replied_msg.from_user.first_name} sent an unrecognizable voice note." + except Exception as e: + logger.error(f"Failed to transcribe replied voice note: {e}") + return f"{replied_msg.from_user.first_name} sent a voice note." + def _clean_and_append_russian_translation(self, response: str, lang: str = DEFAULT_LANGUAGE) -> str: """Extract and translate Russian expressions (both marked and unmarked).""" diff --git a/handlers/voice.py b/handlers/voice.py index 35a305c..250fbd7 100644 --- a/handlers/voice.py +++ b/handlers/voice.py @@ -20,9 +20,10 @@ from core.nlp import NLPEngine, ContextManager from database.database_manager import DatabaseManager, db_manager, get_user_lang from utils.voice_processor import VoiceProcessor +from utils.voice_helpers import send_voice_reply from utils.telegram_helpers import ChatActionSender, start_loading_animation from utils.formatters import format_persona_response -from config.settings import VOICE_ENABLED, DEFAULT_LANGUAGE, ADMIN_IDS, AFFECTION_POINTS +from config.settings import VOICE_ENABLED, DEFAULT_LANGUAGE, ADMIN_IDS, AFFECTION_POINTS, COMMAND_PREFIX logger = logging.getLogger(__name__) @@ -91,8 +92,25 @@ async def handle_voice_message(self, update: Update, context: ContextTypes.DEFAU user = update.effective_user chat = update.effective_chat + # 1. Group Chat Filter Check + is_group_chat = chat.type in ["group", "supergroup"] + is_reply_to_alya = False + has_trigger = False + + if is_group_chat: + if update.message.reply_to_message: + replied = update.message.reply_to_message + if replied.from_user and replied.from_user.id == context.bot.id: + is_reply_to_alya = True + + caption = update.message.caption or "" + has_trigger = caption.lower().startswith((COMMAND_PREFIX, "!tts", "/tts")) + + if not is_reply_to_alya and not has_trigger: + return + try: - # 1. Access Check + # 2. Access Check db_user_dict = self._create_or_update_user(user) db_user = self.db_manager.get_user_object(user.id) if self.db_manager else None is_admin = user.id in ADMIN_IDS or (self.db_manager.is_admin(user.id) if self.db_manager else False) @@ -191,6 +209,17 @@ async def handle_voice_message(self, update: Update, context: ContextTypes.DEFAU except Exception: await update.message.reply_html(ui_text) + # Only send voice reply if it's a private chat or Alya was explicitly addressed in a group + if not is_group_chat or is_reply_to_alya or has_trigger: + source_lang = db_user_dict.get('language_code', DEFAULT_LANGUAGE) + await send_voice_reply( + update=update, + context=context, + text=response, + voice_processor=self.voice_processor, + db_manager=self.db_manager, + source_lang=source_lang + ) # 5. Metadata Update if self.memory_manager: From 6eb585435e839d361a23fcd6aebadba6dc6900f2 Mon Sep 17 00:00:00 2001 From: Afdaan Date: Thu, 19 Mar 2026 01:41:11 +0700 Subject: [PATCH 04/14] fix: remove 'voicelang' from bot commands --- handlers/commands.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/handlers/commands.py b/handlers/commands.py index e399045..4e5c919 100644 --- a/handlers/commands.py +++ b/handlers/commands.py @@ -440,7 +440,6 @@ async def set_bot_commands(application, lang='en') -> None: BotCommand("ping", "🏓 Check bot latency"), BotCommand("stats", "📊 Show bot statistics"), BotCommand("lang", "🌐 Change language (en/id)"), - BotCommand("voicelang", "🎙️ Set voice language (en/id/jp)"), BotCommand("reset", "🔄 Reset your conversation history"), BotCommand("search", "🔍 Search the web"), ] @@ -451,7 +450,6 @@ async def set_bot_commands(application, lang='en') -> None: BotCommand("ping", "🏓 Cek latensi bot"), BotCommand("stats", "📊 Tampilkan statistik bot"), BotCommand("lang", "🌐 Ganti bahasa (en/id)"), - BotCommand("voicelang", "🎙️ Atur bahasa suara (en/id/jp)"), BotCommand("reset", "🔄 Atur ulang riwayat percakapanmu"), BotCommand("search", "🔍 Cari di web"), ] From ac40e48d0687b5234715e62a9b5ef45a2ba70f2d Mon Sep 17 00:00:00 2001 From: Afdaan Date: Thu, 19 Mar 2026 02:56:21 +0700 Subject: [PATCH 05/14] fix: add timeout to loading animation and reply fix --- handlers/conversation.py | 1 + utils/telegram_helpers.py | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/handlers/conversation.py b/handlers/conversation.py index 1128655..a42c422 100644 --- a/handlers/conversation.py +++ b/handlers/conversation.py @@ -571,6 +571,7 @@ async def _process_and_send_response( except Exception as e: logger.error(f"Failed to edit message: {e}") await update.message.reply_html(formatted_response) + else: await update.message.reply_html(formatted_response) if requires_tts: diff --git a/utils/telegram_helpers.py b/utils/telegram_helpers.py index f5ae1be..2812298 100644 --- a/utils/telegram_helpers.py +++ b/utils/telegram_helpers.py @@ -66,10 +66,11 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): # Keep strong references to background animation tasks to prevent garbage collection _active_animations = set() -async def _animate_loading_message(msg: Any, phrase: str, frames: list[str], interval: float): +async def _animate_loading_message(msg: Any, phrase: str, frames: list[str], interval: float, timeout: float = 120.0): """Background task to animate a loading message with a text cycling sequence.""" step = 0 - while True: + max_steps = int(timeout / interval) if interval > 0 else 100 + while step < max_steps: try: await asyncio.sleep(interval) step += 1 @@ -90,11 +91,19 @@ async def _animate_loading_message(msg: Any, phrase: str, frames: list[str], int if "Message to edit not found" in str(e) or "Message can't be edited" in str(e): break + # If the loop finished due to timeout (i.e. not cancelled explicitly or failed with message not found) + if step >= max_steps: + try: + await msg.edit_text(f"
⚠️ {phrase}... (Timeout)
", parse_mode="HTML") + except Exception: + pass + def start_loading_animation( msg: Any, phrase: str, frames: Optional[list[str]] = None, - interval: float = 0.6 + interval: float = 0.6, + timeout: float = 120.0 ) -> asyncio.Task: """ Starts a background animation to edit a loading message periodically. @@ -111,7 +120,7 @@ def start_loading_animation( if frames is None: frames = ["💭", "💫", "✨"] - task = asyncio.create_task(_animate_loading_message(msg, phrase, frames, interval)) + task = asyncio.create_task(_animate_loading_message(msg, phrase, frames, interval, timeout)) _active_animations.add(task) task.add_done_callback(_active_animations.discard) return task From 0f65eff3a328177d48198432ef416a402af7aa88 Mon Sep 17 00:00:00 2001 From: Afdaan Date: Thu, 19 Mar 2026 03:21:19 +0700 Subject: [PATCH 06/14] feat: improve loading animation error handling and timing --- utils/telegram_helpers.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/utils/telegram_helpers.py b/utils/telegram_helpers.py index 2812298..d96ff05 100644 --- a/utils/telegram_helpers.py +++ b/utils/telegram_helpers.py @@ -81,14 +81,16 @@ async def _animate_loading_message(msg: Any, phrase: str, frames: list[str], int except asyncio.CancelledError: break except Exception as e: - if "Message is not modified" not in str(e): - logger.debug(f"Loading animation edit failed: {e}") + err_str = str(e).lower() + if "message is not modified" not in err_str: + logger.debug(f"Loading animation edit failed: {type(e).__name__} - {e}") # Handle Rate Limits gracefully - if "RetryAfter" in str(type(e)) or "flood" in str(e).lower(): - await asyncio.sleep(getattr(e, 'retry_after', 3)) + if "retryafter" in type(e).__name__.lower() or "flood" in err_str or "429" in err_str: + sleep_time = getattr(e, 'retry_after', 5) + await asyncio.sleep(sleep_time) - if "Message to edit not found" in str(e) or "Message can't be edited" in str(e): + if "not found" in err_str or "can't be edited" in err_str or "badrequest" in type(e).__name__.lower(): break # If the loop finished due to timeout (i.e. not cancelled explicitly or failed with message not found) @@ -102,7 +104,7 @@ def start_loading_animation( msg: Any, phrase: str, frames: Optional[list[str]] = None, - interval: float = 0.6, + interval: float = 2.0, timeout: float = 120.0 ) -> asyncio.Task: """ @@ -113,6 +115,7 @@ def start_loading_animation( phrase: The base loading text. frames: List of emojis to cycle through. interval: Time in seconds between animation frames. + timeout: Maximum duration in seconds before the loop halts (default 120s). Returns: The asyncio Task running the animation. From becadab8b3625d7f2f0cc2170ff8c684b68dd6ee Mon Sep 17 00:00:00 2001 From: Afdaan Date: Thu, 19 Mar 2026 03:36:35 +0700 Subject: [PATCH 07/14] fix: tweak loading punctuation and animation interval --- handlers/conversation.py | 2 +- utils/voice_helpers.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/handlers/conversation.py b/handlers/conversation.py index a42c422..9c58abd 100644 --- a/handlers/conversation.py +++ b/handlers/conversation.py @@ -194,7 +194,7 @@ async def chat_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) # Send initial loading message phrase = "Alya is thinking" if lang == "en" else "Alya lagi mikir" - loading_msg = await update.message.reply_text(f"
💭 {phrase}...
", parse_mode="HTML") + loading_msg = await update.message.reply_text(f"
💭 {phrase}.
", parse_mode="HTML") from utils.telegram_helpers import start_loading_animation loading_task = start_loading_animation(loading_msg, phrase) diff --git a/utils/voice_helpers.py b/utils/voice_helpers.py index af53ae3..8a88f49 100644 --- a/utils/voice_helpers.py +++ b/utils/voice_helpers.py @@ -55,7 +55,7 @@ async def send_voice_reply( # 3. Create loading/recording message tts_phrase = "Alya is recording a voice note" if source_lang == 'en' else "Alya lagi ngerekam voice note" tts_loading_msg = await update.message.reply_html( - f"
🎙️ {tts_phrase}...
" + f"
🎙️ {tts_phrase}.
" ) # 4. Start animation @@ -63,7 +63,7 @@ async def send_voice_reply( tts_loading_msg, tts_phrase, frames=["🎙️", "🎶", "✨"], - interval=1.2 + interval=2.5 ) try: From 7a55b37476eb63ff6eb850c590e21e38c72bbdb8 Mon Sep 17 00:00:00 2001 From: Afdaan Date: Thu, 19 Mar 2026 03:43:18 +0700 Subject: [PATCH 08/14] fix: tweak loading animation intervals --- handlers/conversation.py | 2 +- utils/voice_helpers.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/handlers/conversation.py b/handlers/conversation.py index 9c58abd..f05c89a 100644 --- a/handlers/conversation.py +++ b/handlers/conversation.py @@ -197,7 +197,7 @@ async def chat_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) loading_msg = await update.message.reply_text(f"
💭 {phrase}.
", parse_mode="HTML") from utils.telegram_helpers import start_loading_animation - loading_task = start_loading_animation(loading_msg, phrase) + loading_task = start_loading_animation(loading_msg, phrase, interval=0.7) try: message_context = {} diff --git a/utils/voice_helpers.py b/utils/voice_helpers.py index 8a88f49..948a94e 100644 --- a/utils/voice_helpers.py +++ b/utils/voice_helpers.py @@ -63,7 +63,7 @@ async def send_voice_reply( tts_loading_msg, tts_phrase, frames=["🎙️", "🎶", "✨"], - interval=2.5 + interval=3.5 ) try: From 70184400dc0b108e57f2bf8ff0bb5fcbfa4c5fa9 Mon Sep 17 00:00:00 2001 From: Afdaan Date: Thu, 19 Mar 2026 03:50:36 +0700 Subject: [PATCH 09/14] feat: improve ChatActionSender error handling --- utils/telegram_helpers.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/utils/telegram_helpers.py b/utils/telegram_helpers.py index d96ff05..ee53945 100644 --- a/utils/telegram_helpers.py +++ b/utils/telegram_helpers.py @@ -34,21 +34,23 @@ def __init__( self._task = None async def _send_loop(self): - try: - while not self.stop_event.is_set(): + while not self.stop_event.is_set(): + try: await self.bot.send_chat_action( chat_id=self.chat_id, action=self.action, message_thread_id=self.message_thread_id ) + except asyncio.CancelledError: + break + except Exception as e: + logger.warning(f"Warning in ChatActionSender loop: {e}") + + if not self.stop_event.is_set(): try: await asyncio.wait_for(self.stop_event.wait(), timeout=self.interval) except asyncio.TimeoutError: - continue - except asyncio.CancelledError: - pass - except Exception as e: - logger.warning(f"Error in ChatActionSender loop: {e}") + pass async def __aenter__(self): self._task = asyncio.create_task(self._send_loop()) @@ -83,7 +85,7 @@ async def _animate_loading_message(msg: Any, phrase: str, frames: list[str], int except Exception as e: err_str = str(e).lower() if "message is not modified" not in err_str: - logger.debug(f"Loading animation edit failed: {type(e).__name__} - {e}") + logger.warning(f"Loading animation edit failed: {type(e).__name__} - {e}") # Handle Rate Limits gracefully if "retryafter" in type(e).__name__.lower() or "flood" in err_str or "429" in err_str: From f12e6a432237b63d8c6e0c1abd9301457fb7d6b9 Mon Sep 17 00:00:00 2001 From: Afdaan Date: Thu, 19 Mar 2026 03:58:44 +0700 Subject: [PATCH 10/14] fix: simplify loading messages and TTS dispatch --- handlers/conversation.py | 158 ++++++++++++++++++--------------------- utils/voice_helpers.py | 26 +++---- 2 files changed, 86 insertions(+), 98 deletions(-) diff --git a/handlers/conversation.py b/handlers/conversation.py index f05c89a..e07f5dd 100644 --- a/handlers/conversation.py +++ b/handlers/conversation.py @@ -192,92 +192,82 @@ async def chat_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) self.memory.save_user_message(user.id, query) self.context_manager.apply_sliding_window(user.id) - # Send initial loading message + # Send initial static loading message phrase = "Alya is thinking" if lang == "en" else "Alya lagi mikir" - loading_msg = await update.message.reply_text(f"
💭 {phrase}.
", parse_mode="HTML") - - from utils.telegram_helpers import start_loading_animation - loading_task = start_loading_animation(loading_msg, phrase, interval=0.7) + loading_msg = await update.message.reply_text(f"
💭 {phrase}...
", parse_mode="HTML") - try: - message_context = {} - if FEATURES.get("emotion_detection", False) and self.nlp: - message_context = self.nlp.get_message_context(query, user.id) - logger.debug(f"Message context for user {user.id}: {message_context}") - - affection_delta = 0 - if message_context: - affection_delta = self._calculate_affection_delta(user.id, message_context) - - # === STEP 3: Get current mood and calculate new mood === - mood_manager = MoodManager() - - current_mood_data = self.db.get_user_mood(user.id) - user_info = self.db.get_user_relationship_info(user.id) - - new_mood_state = mood_manager.calculate_mood( - current_mood=current_mood_data["mood"], - current_intensity=current_mood_data["intensity"], - affection_delta=affection_delta, - emotion_context=message_context, - relationship_level=user_info["relationship_level"], - last_mood_change=current_mood_data["last_change"] - ) - - # === STEP 4: Apply mood modifier to affection delta === - mood_modifier = mood_manager.get_affection_modifier( - new_mood_state.mood, - affection_delta - ) - modified_affection_delta = int(affection_delta * mood_modifier) - - logger.info( - f"[MOOD] User {user.id}: {current_mood_data['mood']} → {new_mood_state.mood} " - f"(intensity: {new_mood_state.intensity}) | " - f"Affection: {affection_delta} → {modified_affection_delta} (×{mood_modifier:.1f})" - ) - - # === STEP 5: Update affection with mood-modified delta === - if modified_affection_delta != 0: - self.db.update_affection(user.id, modified_affection_delta) - - # === STEP 6: Update mood in database === - updated_history = mood_manager.add_to_mood_history( - current_mood_data["history"], - new_mood_state - ) - self.db.update_user_mood( - user.id, - new_mood_state.mood, - new_mood_state.intensity, - updated_history - ) - - # === STEP 7: Increment interaction count (will recalculate level) === - self.db.increment_interaction_count(user.id) - - # Prepare context with LATEST relationship level and MOOD - user_context = await self._prepare_conversation_context( - user, query, lang, message_context, new_mood_state, mood_manager - ) - response = await self.gemini.generate_response( - user_id=user.id, - username=user.first_name or "user", - message=user_context["enhanced_query"], - context=user_context["system_prompt"], - relationship_level=user_context["relationship_level"], - is_admin=user.id in ADMIN_IDS or self.db.is_admin(user.id), - lang=lang, - retry_count=3, - is_media_analysis=False, - media_context=None - ) - finally: - loading_task.cancel() - try: - await loading_task - except asyncio.CancelledError: - pass + message_context = {} + if FEATURES.get("emotion_detection", False) and self.nlp: + message_context = self.nlp.get_message_context(query, user.id) + logger.debug(f"Message context for user {user.id}: {message_context}") + + affection_delta = 0 + if message_context: + affection_delta = self._calculate_affection_delta(user.id, message_context) + + # === STEP 3: Get current mood and calculate new mood === + mood_manager = MoodManager() + + current_mood_data = self.db.get_user_mood(user.id) + user_info = self.db.get_user_relationship_info(user.id) + + new_mood_state = mood_manager.calculate_mood( + current_mood=current_mood_data["mood"], + current_intensity=current_mood_data["intensity"], + affection_delta=affection_delta, + emotion_context=message_context, + relationship_level=user_info["relationship_level"], + last_mood_change=current_mood_data["last_change"] + ) + + # === STEP 4: Apply mood modifier to affection delta === + mood_modifier = mood_manager.get_affection_modifier( + new_mood_state.mood, + affection_delta + ) + modified_affection_delta = int(affection_delta * mood_modifier) + + logger.info( + f"[MOOD] User {user.id}: {current_mood_data['mood']} → {new_mood_state.mood} " + f"(intensity: {new_mood_state.intensity}) | " + f"Affection: {affection_delta} → {modified_affection_delta} (×{mood_modifier:.1f})" + ) + + # === STEP 5: Update affection with mood-modified delta === + if modified_affection_delta != 0: + self.db.update_affection(user.id, modified_affection_delta) + + # === STEP 6: Update mood in database === + updated_history = mood_manager.add_to_mood_history( + current_mood_data["history"], + new_mood_state + ) + self.db.update_user_mood( + user.id, + new_mood_state.mood, + new_mood_state.intensity, + updated_history + ) + + # === STEP 7: Increment interaction count (will recalculate level) === + self.db.increment_interaction_count(user.id) + + # Prepare context with LATEST relationship level and MOOD + user_context = await self._prepare_conversation_context( + user, query, lang, message_context, new_mood_state, mood_manager + ) + response = await self.gemini.generate_response( + user_id=user.id, + username=user.first_name or "user", + message=user_context["enhanced_query"], + context=user_context["system_prompt"], + relationship_level=user_context["relationship_level"], + is_admin=user.id in ADMIN_IDS or self.db.is_admin(user.id), + lang=lang, + retry_count=3, + is_media_analysis=False, + media_context=None + ) if response: logger.info(f"[RESPONSE_RECEIVED] Got response from Gemini, length={len(response)}") diff --git a/utils/voice_helpers.py b/utils/voice_helpers.py index 948a94e..dc78a81 100644 --- a/utils/voice_helpers.py +++ b/utils/voice_helpers.py @@ -58,6 +58,7 @@ async def send_voice_reply( f"
🎙️ {tts_phrase}.
" ) + from utils.telegram_helpers import start_loading_animation # 4. Start animation tts_loading_task = start_loading_animation( tts_loading_msg, @@ -66,20 +67,17 @@ async def send_voice_reply( interval=3.5 ) - try: - # 5. Dispatch job to TTS microservice (fire and forget) - await dispatch_tts( - bot=context.bot, - chat_id=chat_id, - reply_to_message_id=update.message.message_id, - voice_processor=voice_processor, - response_text=tts_text, - voice_lang=voice_lang, - user_lang=source_lang, - loading_message_id=tts_loading_msg.message_id - ) - finally: - pass + # 5. Dispatch job to TTS microservice (fire and forget) + await dispatch_tts( + bot=context.bot, + chat_id=chat_id, + reply_to_message_id=update.message.message_id, + voice_processor=voice_processor, + response_text=tts_text, + voice_lang=voice_lang, + user_lang=source_lang, + loading_message_id=tts_loading_msg.message_id + ) except Exception as e: logger.error(f"Error in send_voice_reply: {e}", exc_info=True) From 9cdd81874b295293ce2ab7d7c6005234d57153a8 Mon Sep 17 00:00:00 2001 From: Afdaan Date: Thu, 19 Mar 2026 04:06:31 +0700 Subject: [PATCH 11/14] fix: ignore 'not found' errors in loading animation --- utils/telegram_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/telegram_helpers.py b/utils/telegram_helpers.py index ee53945..86a4cf4 100644 --- a/utils/telegram_helpers.py +++ b/utils/telegram_helpers.py @@ -84,7 +84,7 @@ async def _animate_loading_message(msg: Any, phrase: str, frames: list[str], int break except Exception as e: err_str = str(e).lower() - if "message is not modified" not in err_str: + if "message is not modified" not in err_str and "not found" not in err_str: logger.warning(f"Loading animation edit failed: {type(e).__name__} - {e}") # Handle Rate Limits gracefully From d679be20b79751e7011f073dd6b4ed4283cb13b2 Mon Sep 17 00:00:00 2001 From: Afdaan Date: Thu, 19 Mar 2026 04:12:54 +0700 Subject: [PATCH 12/14] fix: bump numpy requirement to >=1.26,<2.0 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c2bec1d..205f87a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ PyYAML>=6.0 google-generativeai>=0.3.0 transformers>=4.35.0 torch>=2.6.0 -numpy>=1.21.0,<=1.23.5 +numpy>=1.26.0,<2.0.0 sqlalchemy>=2.0.41 pymysql>=1.1.0 From c0c3eb65b39fd59e91dacf6bef75304ec660762a Mon Sep 17 00:00:00 2001 From: Afdaan Date: Thu, 19 Mar 2026 04:19:50 +0700 Subject: [PATCH 13/14] fix: update Python installation steps and adjust numpy version constraint --- .github/workflows/deploy-develop-selfhost.yml | 20 ++++++++++++++----- .github/workflows/deploy-feature-selfhost.yml | 20 ++++++++++++++----- requirements.txt | 2 +- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/.github/workflows/deploy-develop-selfhost.yml b/.github/workflows/deploy-develop-selfhost.yml index ab7f825..5ae9994 100644 --- a/.github/workflows/deploy-develop-selfhost.yml +++ b/.github/workflows/deploy-develop-selfhost.yml @@ -15,13 +15,23 @@ jobs: - name: Setup Python environment run: | - if ! command -v python3 &> /dev/null; then - echo "Python3 not found, installing..." - sudo apt update && sudo apt install -y python3 python3-pip python3-venv + export DEBIAN_FRONTEND=noninteractive + sudo apt update && sudo apt install -y software-properties-common + sudo add-apt-repository ppa:deadsnakes/ppa -y || true + sudo apt update || true + sudo apt install -y python3.10 python3.10-pip python3.10-venv python3.10-dev + + VENV_DIR="/opt/dev-Alya-Bot-Telegram/venv" + if [ -d "$VENV_DIR" ]; then + VER=$($VENV_DIR/bin/python --version 2>&1) + if [[ "$VER" != *"3.10"* ]]; then + echo "Version mismatch ($VER). Rebuilding venv for Python 3.10..." + sudo rm -rf "$VENV_DIR" + fi fi - if [ ! -d "/opt/dev-Alya-Bot-Telegram/venv" ]; then - python3 -m venv /opt/dev-Alya-Bot-Telegram/venv + if [ ! -d "$VENV_DIR" ]; then + python3.10 -m venv "$VENV_DIR" fi - name: Prepare and update codebase diff --git a/.github/workflows/deploy-feature-selfhost.yml b/.github/workflows/deploy-feature-selfhost.yml index 33eb26b..2489318 100644 --- a/.github/workflows/deploy-feature-selfhost.yml +++ b/.github/workflows/deploy-feature-selfhost.yml @@ -19,13 +19,23 @@ jobs: - name: Setup Python environment run: | - if ! command -v python3 &> /dev/null; then - echo "Python3 not found, installing..." - sudo apt update && sudo apt install -y python3 python3-pip python3-venv + export DEBIAN_FRONTEND=noninteractive + sudo apt update && sudo apt install -y software-properties-common + sudo add-apt-repository ppa:deadsnakes/ppa -y || true + sudo apt update || true + sudo apt install -y python3.10 python3.10-pip python3.10-venv python3.10-dev + + VENV_DIR="/opt/dev-Alya-Bot-Telegram/venv" + if [ -d "$VENV_DIR" ]; then + VER=$($VENV_DIR/bin/python --version 2>&1) + if [[ "$VER" != *"3.10"* ]]; then + echo "Version mismatch ($VER). Rebuilding venv for Python 3.10..." + sudo rm -rf "$VENV_DIR" + fi fi - if [ ! -d "/opt/dev-Alya-Bot-Telegram/venv" ]; then - python3 -m venv /opt/dev-Alya-Bot-Telegram/venv + if [ ! -d "$VENV_DIR" ]; then + python3.10 -m venv "$VENV_DIR" fi - name: Prepare and update codebase diff --git a/requirements.txt b/requirements.txt index 205f87a..c2bec1d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ PyYAML>=6.0 google-generativeai>=0.3.0 transformers>=4.35.0 torch>=2.6.0 -numpy>=1.26.0,<2.0.0 +numpy>=1.21.0,<=1.23.5 sqlalchemy>=2.0.41 pymysql>=1.1.0 From 174cc70107a6338545820a476ce590198319c48e Mon Sep 17 00:00:00 2001 From: Afdaan Date: Thu, 19 Mar 2026 04:22:06 +0700 Subject: [PATCH 14/14] fix: remove unnecessary Python installation steps from deployment workflows --- .github/workflows/deploy-develop-selfhost.yml | 6 ------ .github/workflows/deploy-feature-selfhost.yml | 6 ------ 2 files changed, 12 deletions(-) diff --git a/.github/workflows/deploy-develop-selfhost.yml b/.github/workflows/deploy-develop-selfhost.yml index 5ae9994..4291ce3 100644 --- a/.github/workflows/deploy-develop-selfhost.yml +++ b/.github/workflows/deploy-develop-selfhost.yml @@ -15,12 +15,6 @@ jobs: - name: Setup Python environment run: | - export DEBIAN_FRONTEND=noninteractive - sudo apt update && sudo apt install -y software-properties-common - sudo add-apt-repository ppa:deadsnakes/ppa -y || true - sudo apt update || true - sudo apt install -y python3.10 python3.10-pip python3.10-venv python3.10-dev - VENV_DIR="/opt/dev-Alya-Bot-Telegram/venv" if [ -d "$VENV_DIR" ]; then VER=$($VENV_DIR/bin/python --version 2>&1) diff --git a/.github/workflows/deploy-feature-selfhost.yml b/.github/workflows/deploy-feature-selfhost.yml index 2489318..aa25986 100644 --- a/.github/workflows/deploy-feature-selfhost.yml +++ b/.github/workflows/deploy-feature-selfhost.yml @@ -19,12 +19,6 @@ jobs: - name: Setup Python environment run: | - export DEBIAN_FRONTEND=noninteractive - sudo apt update && sudo apt install -y software-properties-common - sudo add-apt-repository ppa:deadsnakes/ppa -y || true - sudo apt update || true - sudo apt install -y python3.10 python3.10-pip python3.10-venv python3.10-dev - VENV_DIR="/opt/dev-Alya-Bot-Telegram/venv" if [ -d "$VENV_DIR" ]; then VER=$($VENV_DIR/bin/python --version 2>&1)