diff --git a/.github/workflows/deploy-develop-selfhost.yml b/.github/workflows/deploy-develop-selfhost.yml index ab7f825..4291ce3 100644 --- a/.github/workflows/deploy-develop-selfhost.yml +++ b/.github/workflows/deploy-develop-selfhost.yml @@ -15,13 +15,17 @@ 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 + 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..aa25986 100644 --- a/.github/workflows/deploy-feature-selfhost.yml +++ b/.github/workflows/deploy-feature-selfhost.yml @@ -19,13 +19,17 @@ 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 + 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/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"), ] diff --git a/handlers/conversation.py b/handlers/conversation.py index 6c4e6ea..e07f5dd 100644 --- a/handlers/conversation.py +++ b/handlers/conversation.py @@ -7,12 +7,13 @@ import asyncio import re import os +import tempfile import unicodedata import langdetect 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 +55,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 +108,53 @@ 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 + 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 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 +163,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 @@ -156,96 +192,86 @@ 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) - 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)}") - 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 +532,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: @@ -535,10 +563,43 @@ async def _process_and_send_response( 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) - + + 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 1a5b622..250fbd7 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,11 +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.language_translator import translate_response_for_voice -from utils.tts_queue import dispatch_tts -from utils.telegram_helpers import ChatActionSender +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__) @@ -92,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) @@ -124,7 +141,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,34 +209,17 @@ 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, - 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 - ) + # 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: diff --git a/utils/telegram_helpers.py b/utils/telegram_helpers.py index f5ae1be..86a4cf4 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()) @@ -66,10 +68,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 @@ -80,21 +83,31 @@ 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 and "not found" not in err_str: + logger.warning(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) + 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 = 2.0, + timeout: float = 120.0 ) -> asyncio.Task: """ Starts a background animation to edit a loading message periodically. @@ -104,6 +117,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. @@ -111,7 +125,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 diff --git a/utils/voice_helpers.py b/utils/voice_helpers.py new file mode 100644 index 0000000..dc78a81 --- /dev/null +++ b/utils/voice_helpers.py @@ -0,0 +1,83 @@ +""" +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}." + ) + + from utils.telegram_helpers import start_loading_animation + # 4. Start animation + tts_loading_task = start_loading_animation( + tts_loading_msg, + tts_phrase, + frames=["🎙️", "🎶", "✨"], + interval=3.5 + ) + + # 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)