diff --git a/handlers/conversation.py b/handlers/conversation.py index 6e42597..6c4e6ea 100644 --- a/handlers/conversation.py +++ b/handlers/conversation.py @@ -87,10 +87,16 @@ def _create_or_update_user(self, user) -> bool: ) return is_admin - async def _send_error_response(self, update: Update, username: str, lang: str) -> None: + async def _send_error_response(self, update: Update, username: str, lang: str, loading_msg: Optional[Any] = None) -> None: error_message = self.persona.get_error_message(username=username or "user", lang=lang) formatted_error = format_error_response(error_message) - await update.message.reply_html(formatted_error) + if loading_msg: + try: + await loading_msg.edit_text(formatted_error, parse_mode="HTML") + except Exception: + await update.message.reply_html(formatted_error) + else: + await update.message.reply_html(formatted_error) async def chat_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: user = update.effective_user @@ -150,85 +156,99 @@ 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) - 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) + # 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") + + from utils.telegram_helpers import start_loading_animation + loading_task = start_loading_animation(loading_msg, phrase) - # 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 - ) + 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 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) + await self._process_and_send_response(update, user, response, user_context["message_context"], lang, loading_msg=loading_msg) else: logger.warning(f"[RESPONSE_RECEIVED] Response is empty or None") - await self._send_error_response(update, user.first_name, lang) + await self._send_error_response(update, user.first_name, lang, loading_msg=loading_msg) except Exception as e: logger.error(f"Error in chat command: {e}", exc_info=True) await self._send_error_response(update, user.first_name, lang) @@ -489,7 +509,8 @@ async def _process_and_send_response( user, response: str, message_context: Dict[str, Any], - lang: str + lang: str, + loading_msg: Optional[Any] = None ) -> None: """Clean, format, and send response to Telegram.""" try: @@ -506,10 +527,17 @@ async def _process_and_send_response( formatted_response = format_persona_response(response, use_html=True) formatted_response = f"{formatted_response}\u200C" - await update.message.reply_html(formatted_response) + if loading_msg: + try: + await loading_msg.edit_text(formatted_response, parse_mode="HTML") + 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) except Exception as e: logger.error(f"Error processing response: {e}", exc_info=True) - await self._send_error_response(update, user.first_name, lang) + await self._send_error_response(update, user.first_name, lang, loading_msg=loading_msg) 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 750ee68..1a5b622 100644 --- a/handlers/voice.py +++ b/handlers/voice.py @@ -124,6 +124,13 @@ 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") + + from utils.telegram_helpers import start_loading_animation + loading_task = start_loading_animation(loading_msg, phrase) + # 2. Memory & Relationship Updates self.db_manager.save_message(user.id, "user", user_text) if self.memory_manager: @@ -148,29 +155,43 @@ async def handle_voice_message(self, update: Update, context: ContextTypes.DEFAU # Clean format: [Role] Content history_text = "\n".join([f"[{msg['role'].capitalize()}] {msg['content']}" for msg in history]) - response = await self.gemini_client.generate_response( - user_id=user.id, - username=user.first_name or "user", - message=user_text, - context=self.persona_manager.get_chat_prompt( - username=user.first_name, + try: + response = await self.gemini_client.generate_response( + user_id=user.id, + username=user.first_name or "user", message=user_text, - context=history_text, + context=self.persona_manager.get_chat_prompt( + username=user.first_name, + message=user_text, + context=history_text, + relationship_level=rel_level, + is_admin=is_admin, + lang=db_user_dict.get('language_code', DEFAULT_LANGUAGE) + ), relationship_level=rel_level, is_admin=is_admin, lang=db_user_dict.get('language_code', DEFAULT_LANGUAGE) - ), - relationship_level=rel_level, - is_admin=is_admin, - lang=db_user_dict.get('language_code', DEFAULT_LANGUAGE) - ) + ) + finally: + loading_task.cancel() + try: + await loading_task + except asyncio.CancelledError: + pass if not response: - await update.message.reply_html("❌ Gagal mendapatkan respon dari Alya...") + error_msg = "❌ Gagal mendapatkan respon dari Alya..." + try: + await loading_msg.edit_text(error_msg, parse_mode="HTML") + except Exception: + await update.message.reply_html(error_msg) return ui_text = format_persona_response(response, use_html=True) + "\u200C" - await update.message.reply_html(ui_text) + try: + await loading_msg.edit_text(ui_text, parse_mode="HTML") + 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) @@ -178,6 +199,18 @@ async def handle_voice_message(self, update: Update, context: ContextTypes.DEFAU # 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, @@ -186,6 +219,7 @@ async def handle_voice_message(self, update: Update, context: ContextTypes.DEFAU response_text=tts_text, voice_lang=voice_lang, user_lang=source_lang, + loading_message_id=tts_loading_msg.message_id ) # 5. Metadata Update diff --git a/utils/analyze.py b/utils/analyze.py index 62be210..5cee018 100644 --- a/utils/analyze.py +++ b/utils/analyze.py @@ -1,5 +1,6 @@ import io import logging +import asyncio from pathlib import Path from typing import Dict, Any, Optional, Union, BinaryIO @@ -241,12 +242,15 @@ async def handle_analysis_command(update: Update, context: ContextTypes.DEFAULT_ await message.reply_html(analyze_response(lang)) return - # If no query specified for media, use default + lang = get_user_lang(user.id) if not query and media_type != "text": - lang = get_user_lang(user.id) query = "Analyze this for me, please." if lang == 'en' else "Tolong analisis ini." - await context.bot.send_chat_action(chat_id=update.effective_chat.id, action=ChatAction.TYPING) + phrase = "Alya is analyzing" if lang == 'en' else "Alya sedang menganalisis" + loading_msg = await message.reply_text(f"
🔍 {phrase}...", parse_mode="HTML") + + from utils.telegram_helpers import start_loading_animation + loading_task = start_loading_animation(loading_msg, phrase, frames=["🔍", "🧐", "✨"]) try: result = await analyzer.analyze_media( @@ -263,14 +267,26 @@ async def handle_analysis_command(update: Update, context: ContextTypes.DEFAULT_ lang=lang, username=user.first_name ) - + finally: + loading_task.cancel() + try: + await loading_task + except asyncio.CancelledError: + pass + + try: # Handle if result is a list (long message split) if isinstance(formatted_result, list): - for part in formatted_result: + await loading_msg.edit_text(formatted_result[0], parse_mode='HTML', disable_web_page_preview=True) + for part in formatted_result[1:]: await message.reply_html(part, disable_web_page_preview=True) else: - await message.reply_html(formatted_result, disable_web_page_preview=True) + await loading_msg.edit_text(formatted_result, parse_mode='HTML', disable_web_page_preview=True) except Exception as e: logger.error(f"Failed to handle analysis command for user {user.id}: {e}", exc_info=True) lang = get_user_lang(user.id) - await message.reply_html(get_system_error_response(lang)) + error_response = get_system_error_response(lang) + try: + await loading_msg.edit_text(error_response, parse_mode='HTML') + except Exception: + await message.reply_html(error_response) diff --git a/utils/roast.py b/utils/roast.py index ae485f9..c6a0b8e 100644 --- a/utils/roast.py +++ b/utils/roast.py @@ -137,7 +137,12 @@ async def handle_personal_roast(self, update: Update, context: CallbackContext) await update.message.reply_text(wait_message) return - await update.message.chat.send_action(ChatAction.TYPING) + import asyncio + phrase = "Alya is preparing to roast" if lang == 'en' else "Alya lagi nyiapin materi roast" + 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, frames=["🔥", "😏", "💅"]) try: roast_text = await self._generate_roast(user.first_name, lang) @@ -145,8 +150,17 @@ async def handle_personal_roast(self, update: Update, context: CallbackContext) except Exception as e: logger.error(f"Error generating personal roast for {user.id}: {e}") response = get_roast_response(lang=lang, error='api_fail') + finally: + loading_task.cancel() + try: + await loading_task + except asyncio.CancelledError: + pass - await update.message.reply_text(response, parse_mode=ParseMode.MARKDOWN_V2) + try: + await loading_msg.edit_text(response, parse_mode=ParseMode.MARKDOWN_V2) + except Exception: + await update.message.reply_text(response, parse_mode=ParseMode.MARKDOWN_V2) async def handle_git_roast(self, update: Update, context: CallbackContext) -> None: """Handle GitHub profile roasts.""" @@ -160,7 +174,12 @@ async def handle_git_roast(self, update: Update, context: CallbackContext) -> No github_username = match.group(1) - await update.message.chat.send_action(ChatAction.TYPING) + import asyncio + phrase = "Alya is inspecting the repository" if lang == 'en' else "Alya lagi mantau repository" + 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, frames=["🔍", "🔥", "💅"]) try: github_data = await self._get_github_data(github_username) @@ -175,8 +194,17 @@ async def handle_git_roast(self, update: Update, context: CallbackContext) -> No except Exception as e: logger.error(f"Error during git roast for {github_username}: {e}") response = get_roast_response(lang=lang, error='generic', username=github_username) + finally: + loading_task.cancel() + try: + await loading_task + except asyncio.CancelledError: + pass - await update.message.reply_text(response, parse_mode=ParseMode.MARKDOWN_V2) + try: + await loading_msg.edit_text(response, parse_mode=ParseMode.MARKDOWN_V2) + except Exception: + await update.message.reply_text(response, parse_mode=ParseMode.MARKDOWN_V2) async def _generate_roast(self, name: str, lang: str) -> str: """Generate a personal roast using Gemini with toxic templates.""" diff --git a/utils/telegram_helpers.py b/utils/telegram_helpers.py index b1e6fc1..f5ae1be 100644 --- a/utils/telegram_helpers.py +++ b/utils/telegram_helpers.py @@ -60,8 +60,58 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): try: # Cancel the task to stop it immediately self._task.cancel() - await self._task - except asyncio.CancelledError: - pass except Exception as e: logger.debug(f"ChatActionSender task cleanup error: {e}") + +# 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): + """Background task to animate a loading message with a text cycling sequence.""" + step = 0 + while True: + try: + await asyncio.sleep(interval) + step += 1 + emoji = frames[step % len(frames)] + dots = "." * ((step % 3) + 1) + text = f"
{emoji} {phrase}{dots}" + await msg.edit_text(text, parse_mode="HTML") + 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}") + + # 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 "Message to edit not found" in str(e) or "Message can't be edited" in str(e): + break + +def start_loading_animation( + msg: Any, + phrase: str, + frames: Optional[list[str]] = None, + interval: float = 0.6 +) -> asyncio.Task: + """ + Starts a background animation to edit a loading message periodically. + + Args: + msg: The Telegram message object to edit. + phrase: The base loading text. + frames: List of emojis to cycle through. + interval: Time in seconds between animation frames. + + Returns: + The asyncio Task running the animation. + """ + if frames is None: + frames = ["💭", "💫", "✨"] + + task = asyncio.create_task(_animate_loading_message(msg, phrase, frames, interval)) + _active_animations.add(task) + task.add_done_callback(_active_animations.discard) + return task diff --git a/utils/tts_queue.py b/utils/tts_queue.py index 3bb494c..fda944f 100644 --- a/utils/tts_queue.py +++ b/utils/tts_queue.py @@ -20,6 +20,7 @@ async def dispatch_tts( response_text: str, voice_lang: str, user_lang: str, + loading_message_id: Optional[int] = None ) -> None: """ Send a TTS request to the Alya-TTS microservice. @@ -32,7 +33,8 @@ async def dispatch_tts( "user_lang": user_lang, "chat_id": chat_id, "reply_to_message_id": reply_to_message_id, - "bot_token": BOT_TOKEN + "bot_token": BOT_TOKEN, + "loading_message_id": loading_message_id } async with httpx.AsyncClient() as client: