Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions .github/workflows/deploy-develop-selfhost.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 9 additions & 5 deletions .github/workflows/deploy-feature-selfhost.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions handlers/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
]
Expand All @@ -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"),
]
Expand Down
255 changes: 158 additions & 97 deletions handlers/conversation.py

Large diffs are not rendered by default.

67 changes: 33 additions & 34 deletions handlers/voice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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__)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"🎤 <i>({lang_flag} {detected_lang.upper()}): {user_text}</i>")

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"<blockquote><b>💭 {phrase}...</b></blockquote>", parse_mode="HTML")

Expand Down Expand Up @@ -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"<blockquote><b>🎙️ {tts_phrase}...</b></blockquote>", 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:
Expand Down
46 changes: 30 additions & 16 deletions utils/telegram_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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
Expand All @@ -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"<blockquote><b>⚠️ {phrase}... (Timeout)</b></blockquote>", 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.
Expand All @@ -104,14 +117,15 @@ 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.
"""
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
83 changes: 83 additions & 0 deletions utils/voice_helpers.py
Original file line number Diff line number Diff line change
@@ -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"<blockquote><b>🎙️ {tts_phrase}.</b></blockquote>"
)

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)
Loading