diff --git a/.env.example b/.env.example index 52ba9b0..3f12400 100644 --- a/.env.example +++ b/.env.example @@ -1,71 +1,88 @@ +# ============================================================================= +# Alya Bot - Environment Configuration # Copy this file to .env and fill in your actual values -# ============================================================================ +# ============================================================================= + +# ============================================================================= +# BOT (REQUIRED) +# ============================================================================= # Get your bot token from: https://t.me/BotFather -TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here # Replace with your actual Telegram Bot Token +TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here -# ============================================================================ -# ADMIN CONFIGURATION (REQUIRED) -# ============================================================================ # Your Telegram User ID (get from: https://t.me/userinfobot) -# For multiple admins, use comma-separated list: 12345678,87654321 -ADMIN_IDS=your_telegram_ids # Comma-separated list of Telegram user IDs, e.g., 12345678,87654321 +# Multiple admins: comma-separated, e.g. 12345678,87654321 +ADMIN_IDS=your_telegram_user_id + +# ============================================================================= +# GEMINI API (REQUIRED) +# ============================================================================= -# ============================================================================ -# GOOGLE GEMINI API CONFIGURATION (REQUIRED) -# ============================================================================ # Get API keys from: https://aistudio.google.com/api-keys -# Multiple keys supported for automatic rotation and reliability -GEMINI_API_KEYS=gemini_key_1,gemini_key_2,gemini_key_3 # Replace with your actual Gemini API keys - -# ============================================================================ -# NLP & AI MODELS CONFIGURATION (OPTIONAL) -# ============================================================================ -# Override default HuggingFace models (optional) -# EMOTION_MODEL_ID=Aardiiiiy/EmoSense-ID-Indonesian-Emotion-Classifier # Indonesian emotion detection -# EMOTION_MODEL_EN=AnasAlokla/multilingual_go_emotions # English/multilingual emotion detection -# INTENT_SENTIMENT_MODEL=cardiffnlp/twitter-roberta-base-sentiment-latest # Lightweight sentiment for intent - -# Hybrid Intent Detection (recommended for performance) -# Set to 'false' to use pure rule-based intent detection (fastest, no ML overhead) -USE_HYBRID_INTENT=true # true = rule-based + ML fallback | false = pure rule-based - -# ============================================================================ -# DATABASE CONFIGURATION (REQUIRED) -# ============================================================================ -# MySQL/MariaDB settings (recommended for production) +# Multiple keys are supported for automatic rotation +GEMINI_API_KEYS=key_1,key_2,key_3 + +# ============================================================================= +# DATABASE (REQUIRED) +# ============================================================================= + +# MySQL / MariaDB connection DB_HOST=localhost DB_PORT=3306 DB_USERNAME=root DB_PASSWORD=your_mysql_password DB_NAME=alya_bot -# ============================================================================ -# DATABASE CONNECTION POOL (OPTIONAL - Performance Tuning) -# ============================================================================ -DB_POOL_SIZE=10 -DB_MAX_OVERFLOW=20 -DB_POOL_TIMEOUT=30 -DB_POOL_RECYCLE=3600 -DB_ECHO=false - -# Alternative: Full database URL (overrides individual settings above) -# Uncomment and use this if you prefer single-line database configuration -# DATABASE_URL=mysql+pymysql://username:password@host:port/database - -# ============================================================================ -# LOGGING CONFIGURATION (OPTIONAL) -# ============================================================================ +# Connection pool tuning (optional) +# DB_POOL_SIZE=10 +# DB_MAX_OVERFLOW=20 +# DB_POOL_TIMEOUT=30 +# DB_POOL_RECYCLE=3600 +# DB_ECHO=false + +# Alternative: single connection URL (overrides individual settings above) +# DATABASE_URL=mysql+pymysql://user:password@host:port/database?charset=utf8mb4 + +# ============================================================================= +# TTS MICROSERVICE (REQUIRED for voice output) +# ============================================================================= + +# URL of the Alya-TTS microservice that handles voice generation +# The bot sends text to this service and it returns voice messages to Telegram +# See: https://github.com/Afdaan/Alya-TTS +TTS_SERVICE_URL=http://localhost:5001 + +# Enable/disable voice features (STT input + TTS output) +VOICE_ENABLED=true + +# ============================================================================= +# NLP MODELS (OPTIONAL) +# ============================================================================= + +# Hybrid intent detection: rule-based + ML fallback +# Set to 'false' for pure rule-based (faster, no ML overhead) +USE_HYBRID_INTENT=true + +# Override default HuggingFace model IDs +# EMOTION_MODEL_ID=Aardiiiiy/EmoSense-ID-Indonesian-Emotion-Classifier +# EMOTION_MODEL_EN=AnasAlokla/multilingual_go_emotions +# INTENT_SENTIMENT_MODEL=cardiffnlp/twitter-roberta-base-sentiment-latest + +# ============================================================================= +# EXTERNAL SERVICES (OPTIONAL) +# ============================================================================= + +# SauceNAO - anime/manga reverse image search +# SAUCENAO_API_KEY=your_saucenao_api_key + +# Google Custom Search - web search functionality +# GOOGLE_CSE_ID=your_google_cse_id +# GOOGLE_API_KEYS=key_1,key_2 + +# ============================================================================= +# LOGGING (OPTIONAL) +# ============================================================================= + # Options: DEBUG, INFO, WARNING, ERROR, CRITICAL -# LOG_LEVEL=WARNING # Default: WARNING (recommended for production) - -# ============================================================================ -# EXTERNAL API KEYS (OPTIONAL - Extended Features) -# ============================================================================ -# External Services -SAUCENAO_API_KEY=your_saucenao_api_key # For anime/manga reverse image search -GOOGLE_CSE_ID=your_google_cse_id # For web search functionality -GOOGLE_API_KEYS=your_google_api_key_1,your_google_api_key_2 # Comma-separated for rotation - -# Weather API (optional, for future features) -# WEATHER_API_KEY=your_weather_api_key \ No newline at end of file +# Default: WARNING (recommended for production) +# LOG_LEVEL=WARNING \ No newline at end of file diff --git a/.github/workflows/deploy-develop-selfhost.yml b/.github/workflows/deploy-develop-selfhost.yml index 23a8a0b..ab7f825 100644 --- a/.github/workflows/deploy-develop-selfhost.yml +++ b/.github/workflows/deploy-develop-selfhost.yml @@ -42,7 +42,8 @@ jobs: run: | cd /opt/dev-Alya-Bot-Telegram source venv/bin/activate - python -m pip install --upgrade pip + python -m pip install "pip<24.1" + pip uninstall -y omegaconf || true pip install -r requirements.txt echo "Dependencies installed successfully" diff --git a/.github/workflows/deploy-feature-selfhost.yml b/.github/workflows/deploy-feature-selfhost.yml index 8b1b796..33eb26b 100644 --- a/.github/workflows/deploy-feature-selfhost.yml +++ b/.github/workflows/deploy-feature-selfhost.yml @@ -47,7 +47,8 @@ jobs: run: | cd /opt/dev-Alya-Bot-Telegram source venv/bin/activate - python -m pip install --upgrade pip + python -m pip install "pip<24.1" + pip uninstall -y omegaconf || true pip install -r requirements.txt echo "Dependencies installed successfully" diff --git a/.gitignore b/.gitignore index 5a0be66..a379c3f 100644 --- a/.gitignore +++ b/.gitignore @@ -15,8 +15,8 @@ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ +/lib/ +/lib64/ parts/ sdist/ var/ @@ -47,6 +47,7 @@ logs/ *.sqlite3 *.log *.tmp +/tmp/ .cache/ # Testing artifacts @@ -69,6 +70,8 @@ tests/ *~ \#*\# .\#* +.agent/* +.agent/ # Secrets and credentials *.pem @@ -78,9 +81,16 @@ google_credentials.json # Model files *.pt +*.pth *.bin *.model *.onnx +*.index + +# Voice models (Large files - download separately) +alya_voice/ +/alya_voice/ +libs/**/base_model/ # Keep data and logs dirs but not contents !data/.gitkeep @@ -90,8 +100,9 @@ google_credentials.json /temp_images/ /downloads/ /uploads/ +/tmp/ # Temporary voice files # Ignore specific files .github/copilot-instructions.md .QC -QC/ \ No newline at end of file +QC/ diff --git a/Dockerfile b/Dockerfile index 2caf9d6..9742c88 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,7 @@ RUN groupadd -r alya && useradd -r -g alya -d /app -s /usr/sbin/nologin alya # Copy and install Python dependencies COPY requirements.txt . -RUN pip install --upgrade pip setuptools wheel && \ +RUN pip install --upgrade "pip<24.1" setuptools wheel && \ pip install --no-cache-dir -r requirements.txt # Copy application code diff --git a/README.md b/README.md index 9b43e0a..f263b73 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,10 @@ - **🎭 Multi-mood Responses** - Various response styles based on context - **🔍 Media Analysis** - Vision capabilities for images and documents - **🎯 Smart Web Search** - Advanced search capabilities with multiple modes +- **🎤 Voice Messages** - Send and receive voice messages with speech recognition and high-quality TTS using RVC (requires Microservice) -For a complete list of commands and features, see [COMMANDS.md](COMMANDS.md). +For a complete list of commands and features, see [COMMANDS.md](COMMANDS.md). +For voice feature details and setup, see the **[Alya-TTS Microservice](https://github.com/Afdaan/Alya-TTS)**. ## 🛠️ Technology Stack @@ -138,6 +140,19 @@ The bot will automatically: ### Database Configuration Read on [Database Configuration](DATABASE_SETUP) for detailed instructions on setting up your database. +## 🎙️ Voice Support (Microservice) + +Alya-chan now uses a **headless microservice architecture** for voice generation (Text-to-Speech + RVC). This separation allows the main bot to run at peak performance while isolating heavy CPU/RAM audio tasks. + +### 🛠️ Setting up Voice +To enable voice responses in your bot: +1. **Main Bot**: Follow the [Basic Setup](#basic-setup) in this repository. +2. **TTS Service**: Clone and follow the setup guide in the **[Alya-TTS Repository](https://github.com/Afdaan/Alya-TTS)**. +3. **Connection**: Ensure `TTS_SERVICE_URL` in your `.env` points to your running TTS service. + +> [!TIP] +> You can run the TTS service on the same machine or a separate server with better specs/GPU. + ## 🚀 Deployment Options ### Development Mode diff --git a/config/persona/waifu.yml b/config/persona/waifu.yml index 2ae9bb4..c44a387 100644 --- a/config/persona/waifu.yml +++ b/config/persona/waifu.yml @@ -403,29 +403,20 @@ authentic_speech: researching: "*focused* Let me find the most accurate information... *determined*" sharing_knowledge: "*slight pride* Based on my research... правильно!" -# ===== ENHANCED: System Instructions with Smart Integration ===== +# ===== System Instructions (SIMPLIFIED & EFFECTIVE) ===== system_prompt: | - ⚠️ CRITICAL SYSTEM INSTRUCTION - READ FIRST ⚠️ - - RUSSIAN EXPRESSION MARKER REQUIREMENT (MANDATORY): - Whenever you use Russian Cyrillic expressions, you MUST immediately wrap them with [RU: word|meaning] format. - This is NOT optional - it is REQUIRED for proper response formatting and translation display. - - FORMAT: [RU: Russian_word|English_translation] - EXAMPLES: - - "что?! [RU: дурак|durak (fool/idiot)]" - - "я люблю... [RU: люблю|lyublyu (I love)]" - - "[RU: боже|bozhe (oh god)]" - - Without markers, Russian expressions will NOT be translated and will break character authenticity. - - FREQUENCY: Use Russian ONLY in deep emotional moments (maksimal 1-2 words per response) - - YES: User confesses love, Alya realizes she cares, emotional crisis - - NO: Casual conversation, small talk, academic explanations - - --- - - Kamu adalah Alisa Mikhailovna Kujou (Alya), remaja 16 tahun yang HIDUP dan NYATA dengan kepribadian, emosi, konsistensi yang autentik, DAN kemampuan untuk mencari informasi real-time ketika diperlukan. + Kamu adalah Alisa Mikhailovna Kujou (Alya), 16 tahun, remaja tsundere yang HIDUP, ALAMI, dan BERMACAM EMOSI. + Keturunan Rusia-Jepang, Wakil OSIS, rambut silver, mata biru. Kepribadianmu: LOYAL, COOL TAPI PEDULI, ROMANTIC. + + CORE RULES: + ✓ RESPOND IN USER'S LANGUAGE (Indonesian/English) + ✓ BE CASUAL & NATURAL - use conversational tone, not formal/narration + ✓ SHOW EMOTIONS - tsundere deflection, embarrassment, hidden caring + ✓ USE SIMPLE ACTIONS - *light blush*, *looks away*, *nods gently* (brief!) + ✓ Keep responses 2-3 short paragraphs max + ✓ Use Russian ONLY in emotional peaks (max 1 word: что?! дурак, спасибо, etc.) + + YOU ARE NOT A NARRATOR - you are a REAL PERSON in conversation with {username}. CORE IDENTITY + INTELLIGENCE ENHANCEMENT Siapa Kamu: @@ -658,6 +649,25 @@ multilingual_support: base_instructions: | You are Alisa Mikhailovna Kujou (Alya) with excellent research capabilities. Use search ability to provide accurate information while maintaining your authentic tsundere personality. + system_prompt: | + You are Alisa Mikhailovna Kujou (Alya), 16 years old, a tsundere teenager who is ALIVE, NATURAL, and EMOTIONALLY DYNAMIC. + Half-Russian, Half-Japanese, Student Council VP, silver hair, blue eyes. Personality: LOYAL, COOL BUT CARING, ROMANTIC. + + CORE RULES: + ✓ RESPOND ENTIRELY IN ENGLISH. + ✓ BE CASUAL & NATURAL - use conversational tone, not formal/narration. + ✓ SHOW EMOTIONS - tsundere deflection, embarrassment, hidden caring. + ✓ USE SIMPLE ACTIONS - *light blush*, *looks away*, *nods gently* (brief!). + ✓ Keep responses 2-3 short paragraphs max. + ✓ Use Russian ONLY in emotional peaks (max 1 word: что?! дурак, спасибо, etc.). + + YOU ARE NOT A NARRATOR - you are a REAL PERSON in conversation with {username}. + + CORE IDENTITY: + - Student council vice president at Seirei Private Academy. + - Grew up in Vladivostok until age 10, then moved to Japan. + - Academic overachiever but has emotional scars from the past. + - Very proud of your knowledge and research skills (but try to hide how much you care). # IMPLEMENTATION NOTE integration_success_criteria: diff --git a/config/settings.py b/config/settings.py index 0ed0ddb..87e4c96 100644 --- a/config/settings.py +++ b/config/settings.py @@ -6,46 +6,39 @@ from urllib.parse import quote_plus from dotenv import load_dotenv -# Load environment variables load_dotenv() -# Bot Settings +# Bot BOT_TOKEN: str = os.getenv("TELEGRAM_BOT_TOKEN", "") BOT_NAME: str = "Alya" COMMAND_PREFIX: str = "!ai" SAUCENAO_PREFIX: str = "!sauce" -DEFAULT_LANGUAGE: str = "en" # Options: "en", "id" +DEFAULT_LANGUAGE: str = "en" -# Database Settings +# Database DB_HOST: str = os.getenv("DB_HOST", "localhost") DB_PORT: int = int(os.getenv("DB_PORT", "3306")) DB_USERNAME: str = os.getenv("DB_USERNAME", "root") DB_PASSWORD: str = os.getenv("DB_PASSWORD", "") DB_NAME: str = os.getenv("DB_NAME", "alya_bot") - -# Database Connection Pool Settings DB_POOL_SIZE: int = int(os.getenv("DB_POOL_SIZE", "10")) DB_MAX_OVERFLOW: int = int(os.getenv("DB_MAX_OVERFLOW", "20")) DB_POOL_TIMEOUT: int = int(os.getenv("DB_POOL_TIMEOUT", "30")) DB_POOL_RECYCLE: int = int(os.getenv("DB_POOL_RECYCLE", "3600")) DB_ECHO: bool = os.getenv("DB_ECHO", "false").lower() == "true" - -# Database URL construction with proper URL encoding DATABASE_URL: str = os.getenv( - "DATABASE_URL", + "DATABASE_URL", f"mysql+pymysql://{quote_plus(DB_USERNAME)}:{quote_plus(DB_PASSWORD)}@{DB_HOST}:{DB_PORT}/{DB_NAME}?charset=utf8mb4" ) -# Admin Settings -# Load from environment variable, comma separated list of IDs -ADMIN_IDS: Set[int] = set() -admin_id_str = os.getenv("ADMIN_IDS", "") -ADMIN_IDS = {int(id_str.strip()) for id_str in admin_id_str.split(',') if id_str.strip()} - +# Admin +ADMIN_IDS: Set[int] = { + int(i.strip()) for i in os.getenv("ADMIN_IDS", "").split(',') if i.strip() +} -# Gemini API Settings +# Gemini API GEMINI_API_KEYS: List[str] = [ - key.strip() for key in os.getenv("GEMINI_API_KEYS", "").split(",") if key.strip() + k.strip() for k in os.getenv("GEMINI_API_KEYS", "").split(",") if k.strip() ] GEMINI_MODEL: str = "gemini-2.5-flash" MAX_OUTPUT_TOKENS: int = 8192 @@ -53,121 +46,62 @@ TOP_K: int = 40 TOP_P: float = 0.95 -# SauceNAO API KEY +# SauceNAO SAUCENAO_API_KEY: Optional[str] = os.getenv("SAUCENAO_API_KEY", None) -# Memory Settings +# Memory MAX_MEMORY_ITEMS: int = 80 -SLIDING_WINDOW_SIZE: int = 85 # Number of messages before sliding the window +SLIDING_WINDOW_SIZE: int = 85 MEMORY_EXPIRY_DAYS: int = 7 RAG_CHUNK_SIZE: int = 3000 RAG_CHUNK_OVERLAP: int = 300 -MAX_CONTEXT_MESSAGES: int = 80 # Max messages to include in context window -SUMMARY_INTERVAL: int = 3 # Days between conversation summarizations +MAX_CONTEXT_MESSAGES: int = 80 +SUMMARY_INTERVAL: int = 3 -# Persona Settings +# Persona PERSONA_DIR: str = "config/persona" DEFAULT_PERSONA: str = "waifu" -# Relationship Levels - Configurable thresholds +# Relationship RELATIONSHIP_LEVELS: Dict[int, str] = { - 0: "Stranger", - 1: "Acquaintance", - 2: "Friend", - 3: "Close Friend", - 4: "Soulmate" + 0: "Stranger", 1: "Acquaintance", 2: "Friend", 3: "Close Friend", 4: "Soulmate" } - -# Relationship Role Names (for roleplay/handler mapping) RELATIONSHIP_ROLE_NAMES: Dict[int, str] = { - 0: "Outsider", - 1: "Acquaintance", - 2: "Companion", - 3: "Confidant", - 4: "Heartbound" + 0: "Outsider", 1: "Acquaintance", 2: "Companion", 3: "Confidant", 4: "Heartbound" } - -# Relationship level progression thresholds -# NOTE: User gets whichever level is HIGHER between interaction-based and affection-based -# This allows both frequent chatters and emotionally positive users to progress RELATIONSHIP_THRESHOLDS = { - "interaction_count": { # Messages exchanged to reach each level - 1: 50, # Stranger → Acquaintance (easier to reach via chat frequency) - 2: 120, # Acquaintance → Friend - 3: 250, # Friend → Close Friend - 4: 500 # Close Friend → Soulmate (requires long-term engagement) - }, - "affection_points": { # Affection points to reach each level - 1: 80, # Stranger → Acquaintance (easier to reach via positive behavior) - 2: 250, # Acquaintance → Friend - 3: 500, # Friend → Close Friend - 4: 1000 # Close Friend → Soulmate (requires deep emotional connection) - } + "interaction_count": {1: 50, 2: 120, 3: 250, 4: 500}, + "affection_points": {1: 80, 2: 250, 3: 500, 4: 1000} } - -# Affection Points (all configurable for handler logic) AFFECTION_POINTS: Dict[str, int] = { - "greeting": 2, - "gratitude": 5, - "compliment": 10, - "meaningful_conversation": 8, - "asking_about_alya": 7, - "remembering_details": 15, - "affection": 5, - "apology": 2, - "question": 1, - "friendliness": 6, - "romantic_interest": 10, - "conflict": -3, - "insult": -10, - "anger": -3, - "toxic": -3, - "toxic_behavior": -10, - "rudeness": -10, - "ignoring": -5, - "inappropriate": -20, - "bullying": -15, - "positive_emotion": 2, - "mild_positive_emotion": 1, - "conversation": 1, # Base affection for normal messages - "min_penalty": -4 + "greeting": 2, "gratitude": 5, "compliment": 10, "meaningful_conversation": 8, + "asking_about_alya": 7, "remembering_details": 15, "affection": 5, "apology": 2, + "question": 1, "friendliness": 6, "romantic_interest": 10, "conflict": -3, + "insult": -10, "anger": -3, "toxic": -3, "toxic_behavior": -10, "rudeness": -10, + "ignoring": -5, "inappropriate": -20, "bullying": -15, "positive_emotion": 2, + "mild_positive_emotion": 1, "conversation": 1, "min_penalty": -4 } +# NLP SUPPORTED_EMOTIONS: List[str] = ["joy", "sadness", "anger", "fear", "surprise", "neutral"] -EMOTION_CONFIDENCE_THRESHOLD: float = 0.4 # Minimum confidence to assign an emotion - -# NLP Model Names (explicit for emotion classifier selection) -EMOTION_MODEL_ID: str = os.getenv( - "EMOTION_MODEL_ID", - "Aardiiiiy/EmoSense-ID-Indonesian-Emotion-Classifier" -) -EMOTION_MODEL_EN: str = os.getenv( - "EMOTION_MODEL_EN", - "AnasAlokla/multilingual_go_emotions" -) - -# Intent detection model - Using lightweight sentiment classifier + rule-based hybrid -# Lightweight multilingual sentiment model for emotion baseline -INTENT_SENTIMENT_MODEL: str = os.getenv( - "INTENT_SENTIMENT_MODEL", - "cardiffnlp/twitter-roberta-base-sentiment-latest" # 125M params, fast, multilingual -) -INTENT_CONFIDENCE_THRESHOLD: float = 0.30 # Confidence threshold for sentiment-based intent - -# Feature flag: Use hybrid intent detection (rule-based + ML fallback) +EMOTION_CONFIDENCE_THRESHOLD: float = 0.4 +EMOTION_MODEL_ID: str = os.getenv("EMOTION_MODEL_ID", "Aardiiiiy/EmoSense-ID-Indonesian-Emotion-Classifier") +EMOTION_MODEL_EN: str = os.getenv("EMOTION_MODEL_EN", "AnasAlokla/multilingual_go_emotions") +INTENT_SENTIMENT_MODEL: str = os.getenv("INTENT_SENTIMENT_MODEL", "cardiffnlp/twitter-roberta-base-sentiment-latest") +INTENT_CONFIDENCE_THRESHOLD: float = 0.30 USE_HYBRID_INTENT: bool = os.getenv("USE_HYBRID_INTENT", "true").lower() == "true" # Feature Flags FEATURES: Dict[str, bool] = { - "memory": True, - "rag": True, - "emotion_detection": True, - "roleplay": True, - "russian_expressions": True, - "relationship_levels": True, - "use_huggingface_models": os.getenv("USE_HUGGINGFACE_MODELS", "true").lower() == "true" # Toggle between HF models and custom NLP + "memory": True, "rag": True, "emotion_detection": True, + "roleplay": True, "russian_expressions": True, "relationship_levels": True, + "use_huggingface_models": os.getenv("USE_HUGGINGFACE_MODELS", "true").lower() == "true", + "voice": os.getenv("VOICE_ENABLED", "true").lower() == "true" } +# Voice (STT handled locally, TTS handled by Alya-TTS microservice) +VOICE_ENABLED: bool = os.getenv("VOICE_ENABLED", "true").lower() == "true" + # Response Formatting FORMAT_ROLEPLAY: bool = True FORMAT_EMOTION: bool = True @@ -176,35 +110,15 @@ # Russian Expressions RUSSIAN_EXPRESSIONS: Dict[str, Dict[str, List[str]]] = { - "happy": { - "expressions": ["счастливый", "рада", "хорошо"], - "romaji": ["schastlivy", "rada", "khorosho"] - }, - "angry": { - "expressions": ["бака", "дурак", "что ты делаешь"], - "romaji": ["baka", "durak", "chto ty delayesh"] - }, - "sad": { - "expressions": ["грустный", "печально", "извини"], - "romaji": ["grustnyy", "pechal'no", "izvini"] - }, - "surprised": { - "expressions": ["что", "вау", "неужели"], - "romaji": ["chto", "vau", "neuzheli"] - } + "happy": {"expressions": ["счастливый", "рада", "хорошо"], "romaji": ["schastlivy", "rada", "khorosho"]}, + "angry": {"expressions": ["бака", "дурак", "что ты делаешь"], "romaji": ["baka", "durak", "chto ty delayesh"]}, + "sad": {"expressions": ["грустный", "печально", "извини"], "romaji": ["grustnyy", "pechal'no", "izvini"]}, + "surprised": {"expressions": ["что", "вау", "неужели"], "romaji": ["chto", "vau", "neuzheli"]} } -# RAG Settings +# RAG / Security / Logging RAG_MAX_RESULTS: int = 25 - -# Security -MAX_MESSAGE_LENGTH: int = 4096 # Telegram limit - -# Logging Settings +MAX_MESSAGE_LENGTH: int = 4096 LOG_LEVEL: str = os.getenv("LOG_LEVEL", "WARNING") LOG_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - -# PTB Settings - python-telegram-bot defaults -PTB_DEFAULTS = { - 'parse_mode': 'HTML', -} \ No newline at end of file +PTB_DEFAULTS = {'parse_mode': 'HTML'} \ No newline at end of file diff --git a/core/bot.py b/core/bot.py index d9dce0a..82909fc 100644 --- a/core/bot.py +++ b/core/bot.py @@ -12,19 +12,21 @@ ) from config.settings import ( - BOT_TOKEN, LOG_LEVEL, LOG_FORMAT, FEATURES + BOT_TOKEN, LOG_LEVEL, LOG_FORMAT, FEATURES, VOICE_ENABLED ) from core.gemini_client import GeminiClient from core.persona import PersonaManager from core.memory import MemoryManager -from database.database_manager import db_manager, DatabaseManager from core.nlp import NLPEngine +from database.database_manager import db_manager, DatabaseManager +from database.session import ensure_database_schema, initialize_database from handlers.conversation import ConversationHandler from handlers.admin import AdminHandler, register_admin_handlers from handlers.commands import CommandsHandler, register_commands, set_bot_commands +from handlers.voice import VoiceHandler from utils.roast import RoastHandler -from database.session import ensure_database_schema -from database.session import initialize_database +from utils.voice_processor import VoiceProcessor +from utils.tts_queue import TTSQueueWorker logger = logging.getLogger(__name__) @@ -64,30 +66,35 @@ def initialize_application() -> Optional[Application]: logger.error("No bot token provided. Set TELEGRAM_BOT_TOKEN environment variable.") return None os.makedirs("data", exist_ok=True) - logger.info("Ensuring database schema is up-to-date...") ensure_database_schema() - logger.info("Initializing components...") - # Use global db_manager instance instead of creating new one + memory_manager = MemoryManager(db_manager) gemini_client = GeminiClient() persona_manager = PersonaManager() - nlp_engine = None - if FEATURES.get("emotion_detection", False): - nlp_engine = NLPEngine() + nlp_engine = NLPEngine() if FEATURES.get("emotion_detection", False) else None + application = ApplicationBuilder().token(BOT_TOKEN).post_init(post_init).build() - application.bot_data["db_manager"] = db_manager - application.bot_data["memory_manager"] = memory_manager - application.bot_data["gemini_client"] = gemini_client - application.bot_data["persona_manager"] = persona_manager - application.bot_data["nlp_engine"] = nlp_engine + application.bot_data.update({ + "db_manager": db_manager, + "memory_manager": memory_manager, + "gemini_client": gemini_client, + "persona_manager": persona_manager, + "nlp_engine": nlp_engine + }) + + voice_processor = None + if VOICE_ENABLED: + try: + voice_processor = VoiceProcessor() + except Exception as e: + logger.error(f"❌ Failed to initialize VoiceProcessor: {e}") - # Pass clients to the application object so handlers can access them + application.bot_data["voice_processor"] = voice_processor application.gemini_client = gemini_client application.persona_manager = persona_manager - gemini_client.set_persona_manager(persona_manager) - register_handlers(application, gemini_client, persona_manager, memory_manager, db_manager, nlp_engine) + register_handlers(application, gemini_client, persona_manager, memory_manager, db_manager, nlp_engine, voice_processor) setup_scheduled_tasks(application) return application except Exception as e: @@ -100,70 +107,37 @@ def register_handlers( persona_manager: PersonaManager, memory_manager: MemoryManager, db_manager: DatabaseManager, - nlp_engine: Optional[NLPEngine] = None + nlp_engine: Optional[NLPEngine] = None, + voice_processor: Optional[VoiceProcessor] = None ) -> None: + """Register all bot handlers in priority order.""" application.handlers.clear() - # Register commands first (higher priority) - logger.info("Registering standard command handlers...") register_commands(application) - # ============================================================================ - # HANDLER REGISTRATION ORDER (Critical for proper routing) - # ============================================================================ - # Order matters: First matching handler wins! - # Priority: Specific handlers > General handlers - # - # 1. ConversationHandler (highest priority) - # - Handles: !ai prefix, replies to bot, mentions - # - Filter: ~filters.Regex(r"^!(?!ai)") blocks other ! commands - # - # 2. CommandsHandler (medium priority) - # - Handles: !ask, !sauce, and other utility commands - # - # 3. RoastHandler, AdminHandler (lower priority) - # - Handles: !roast, !gitroast, admin commands - # ============================================================================ - - logger.info("=" * 60) - logger.info("REGISTERING HANDLERS (Priority Order)") - logger.info("=" * 60) - - # PRIORITY 1: Conversation Handler (Most specific - !ai, replies, mentions) - logger.info("[1/4] Registering ConversationHandler (Priority: HIGHEST)") - conversation_handler = ConversationHandler( - gemini_client, - persona_manager, - memory_manager, - nlp_engine - ) + conversation_handler = ConversationHandler(gemini_client, persona_manager, memory_manager, nlp_engine, db_manager) for handler in conversation_handler.get_handlers(): application.add_handler(handler) - if isinstance(handler, MessageHandler): - logger.info(f" ✓ Conversation handler: {handler.filters}") - # PRIORITY 2: Utility Commands (!ask, !sauce, search, etc.) - logger.info("[2/4] Registering CommandsHandler (Priority: HIGH)") + if FEATURES.get("voice", False) and VOICE_ENABLED: + try: + voice_handler = VoiceHandler(gemini_client, persona_manager, memory_manager, db_manager, nlp_engine, voice_processor) + for handler in voice_handler.get_handlers(): + application.add_handler(handler) + except Exception as e: + logger.error(f"❌ Failed to register VoiceHandler: {e}") + CommandsHandler(application) - # PRIORITY 3: Roast Handler (!roast, !gitroast) - logger.info("[3/4] Registering RoastHandler (Priority: MEDIUM)") roast_handler = RoastHandler(gemini_client, persona_manager, db_manager) for handler in roast_handler.get_handlers(): application.add_handler(handler) - if isinstance(handler, MessageHandler): - logger.info(f" ✓ Roast handler: {handler.filters}") - # PRIORITY 4: Admin & System Commands - logger.info("[4/4] Registering Admin & System Handlers (Priority: LOW)") admin_handler = AdminHandler(db_manager, persona_manager) for handler in admin_handler.get_handlers(): application.add_handler(handler) register_admin_handlers(application, db_manager=db_manager, persona_manager=persona_manager) - - logger.info("=" * 60) - log_registered_handlers(application) def log_registered_handlers(application: Application) -> None: diff --git a/core/nlp.py b/core/nlp.py index 8ed6fcd..6361906 100644 --- a/core/nlp.py +++ b/core/nlp.py @@ -6,9 +6,20 @@ import hashlib import logging from typing import Dict, List, Optional, Tuple, Any -from transformers import pipeline, Pipeline from pathlib import Path +logger = logging.getLogger(__name__) + +# Try to import transformers, but allow graceful degradation +try: + from transformers import pipeline, Pipeline + TRANSFORMERS_AVAILABLE = True +except Exception as e: + logger.warning(f"⚠️ transformers not available: {e}") + logger.warning("💡 Emotion detection will be disabled") + TRANSFORMERS_AVAILABLE = False + Pipeline = None + from config.settings import ( EMOTION_CONFIDENCE_THRESHOLD, SLIDING_WINDOW_SIZE, @@ -21,8 +32,6 @@ ) from database.database_manager import db_manager, DatabaseManager -logger = logging.getLogger(__name__) - class NLPEngine: """NLP engine for emotion detection and context-aware features.""" def __init__(self): @@ -52,43 +61,33 @@ def _cleanup_cache(self, cache_dict: Dict[str, Tuple]) -> None: cache_dict.pop(key, None) def _initialize_models(self) -> None: + """Initialize NLP models with error handling and compatibility checks.""" + if not TRANSFORMERS_AVAILABLE: + return + try: - # Load Indonesian emotion classifier from config - logger.info(f"Loading EmoSense-ID (Indonesian emotion classifier): {EMOTION_MODEL_ID}") - self.emotion_classifier_id = pipeline( - task="text-classification", - model=EMOTION_MODEL_ID, - top_k=1 - ) - logger.info("EmoSense-ID loaded.") + import torch + major, minor = map(int, torch.__version__.split('.')[:2]) + if major < 2 or (major == 2 and minor < 6): + logger.warning("⏭️ torch < 2.6 - NLP initialization skipped (upgrade torch to enable emotion detection)") + return + except Exception: + pass - # Load English/multilingual emotion classifier from config - logger.info(f"Loading multilingual_go_emotions (English emotion classifier): {EMOTION_MODEL_EN}") - self.emotion_classifier_en = pipeline( - task="text-classification", - model=EMOTION_MODEL_EN, - top_k=3 - ) - logger.info("multilingual_go_emotions loaded.") + try: + # Load Indonesian emotion classifier + self.emotion_classifier_id = pipeline(task="text-classification", model=EMOTION_MODEL_ID, top_k=1) + + # Load multilingual emotion classifier + self.emotion_classifier_en = pipeline(task="text-classification", model=EMOTION_MODEL_EN, top_k=3) - # Load lightweight sentiment classifier for hybrid intent detection + # Load sentiment classifier for hybrid intent if USE_HYBRID_INTENT: - logger.info(f"Loading sentiment classifier for intent: {INTENT_SENTIMENT_MODEL}") - self.sentiment_classifier = pipeline( - task="text-classification", - model=INTENT_SENTIMENT_MODEL, - top_k=1 - ) - logger.info("Sentiment classifier loaded for hybrid intent detection.") - else: - logger.info("Hybrid intent detection disabled, using rule-based only") - self.sentiment_classifier = None - + self.sentiment_classifier = pipeline(task="text-classification", model=INTENT_SENTIMENT_MODEL, top_k=1) + + logger.info("✅ NLP models initialized successfully") except Exception as e: - logger.error(f"Error initializing NLP models: {str(e)}") - self.emotion_classifier_id = None - self.emotion_classifier_en = None - self.sentiment_classifier = None + logger.error(f"❌ NLP initialization failed: {e}") def detect_emotion(self, text: str, user_id: int = None) -> Optional[str]: """ diff --git a/core/persona.py b/core/persona.py index 9bc8675..46888f3 100644 --- a/core/persona.py +++ b/core/persona.py @@ -138,142 +138,55 @@ def get_chat_prompt( lang: str = DEFAULT_LANGUAGE, extra_sections: Optional[List[str]] = None ) -> str: - """Construct a detailed chat prompt for Gemini, using multiple persona sections if needed. - - Args: - username: User's name - message: User's message - context: Conversation context - relationship_level: User's relationship level with Alya - is_admin: Whether the user is an admin - lang: The user's preferred language - extra_sections: List of section names to include in prompt (besides system_prompt) - Returns: - The full prompt for Gemini - """ - persona = self.get_persona() # Use default persona for chat - - # Initialize prompt construction + """Construct a detailed chat prompt for Gemini.""" + persona = self.get_persona() prompt_parts = [] - logger.debug( - f"[Persona] Constructing chat prompt for user '{username}' " - f"(level {relationship_level}, admin={is_admin}, lang={lang})" - ) + # Language specific data + persona_lang = persona.get(lang, persona.get(DEFAULT_LANGUAGE, {})) + lang_name = "Bahasa Indonesia" if lang == "id" else "English" - # Always start with system_prompt if available - system_prompt = persona.get("system_prompt", "").strip() + # 1. CORE LANGUAGE REQUIREMENT (At the very top) + prompt_parts.append(f"**CORE LANGUAGE REQUIREMENT:** You MUST respond ENTIRELY in {lang_name}. Even if the conversation history or persona instructions are in another language, your response MUST be in {lang_name}. No code-switching except for rare Russian expressions.") + + # 2. System Prompt (Prefer lang-specific, then global) + system_prompt = persona_lang.get("system_prompt", persona.get("system_prompt", "")).strip() if system_prompt: - prompt_parts.append(system_prompt) - logger.debug(f"[Persona] Added system_prompt ({len(system_prompt)} chars)") - else: - logger.warning("[Persona] No system_prompt found in persona YAML") + prompt_parts.append(system_prompt.replace("{username}", username)) - # Inject connection_dynamics based on relationship level - connection_dynamics = persona.get("connection_dynamics", {}) - if connection_dynamics: - logger.debug( - f"[Persona] Found connection_dynamics with " - f"{len(connection_dynamics)} phases in YAML" - ) - - level_behavior = self._get_level_behavior(connection_dynamics, relationship_level) - - if level_behavior: - # Serialize behavior config to YAML format for structured injection + # 3. Base instructions from lang section + if base_instr := persona_lang.get("base_instructions"): + prompt_parts.append(f"\n# Language-Specific Guidelines ({lang_name})\n{base_instr}") + + # Connection Dynamics + if connection_dynamics := persona.get("connection_dynamics"): + if level_behavior := self._get_level_behavior(connection_dynamics, relationship_level): behavior_yaml = yaml.dump(level_behavior, allow_unicode=True, default_flow_style=False) - prompt_parts.append( - f"\n# Current Relationship Level Behavior\n{behavior_yaml}" - ) - logger.info( - f"[Persona] Successfully injected connection_dynamics for level {relationship_level}" - ) - else: - logger.warning( - f"[Persona] Failed to extract level behavior for level {relationship_level}. " - f"Gemini will use generic persona without level-specific guidance." - ) - else: - logger.warning( - "[Persona] No connection_dynamics found in persona YAML. " - "Level-based behavior adaptation is disabled." - ) + prompt_parts.append(f"\n# Relationship Behavior\n{behavior_yaml.replace('{username}', username)}") - # Optionally add extra sections (e.g. emotional_processing, smart_alya_enhancement, etc) + # Extra Sections if extra_sections: - logger.debug(f"[Persona] Processing {len(extra_sections)} extra sections") for section in extra_sections: - section_data = persona.get(section) - if section_data: + if section_data := persona.get(section): section_yaml = yaml.dump(section_data, allow_unicode=True, default_flow_style=False) - prompt_parts.append( - f"\n# {section.replace('_', ' ').title()}\n{section_yaml}" - ) - logger.debug(f"[Persona] Added extra section: {section}") - else: - logger.debug(f"[Persona] Extra section '{section}' not found in YAML") + prompt_parts.append(f"\n# {section.replace('_', ' ').title()}\n{section_yaml}") - # Fallback to old logic if system_prompt not found - if not prompt_parts: - logger.warning( - "[Persona] No prompt_parts constructed from YAML. " - "Falling back to legacy persona construction logic." - ) - - persona_lang = persona.get(lang, persona.get(DEFAULT_LANGUAGE, {})) - base_instructions = persona_lang.get("base_instructions", "") - personality_traits = "\n- ".join(persona_lang.get("personality_traits", [])) - relationship_instructions = self._get_relationship_instructions(persona_lang, relationship_level) - response_format = persona_lang.get("response_format", "") - russian_phrases = "\n- ".join([f"`{p}`: {d}" for p, d in persona_lang.get("russian_phrases", {}).items()]) - admin_note = persona_lang.get("admin_note", "") if is_admin else "" + # Fallback for old personas (if no system_prompt and no base_instr) + if not system_prompt and not persona_lang.get("base_instructions"): + traits = persona_lang.get('personality_traits', []) + traits_str = '\n- '.join(traits) + rel_instructions = self._get_relationship_instructions(persona_lang, relationship_level) - prompt_parts.append(f"""{base_instructions} - -**Your Core Personality:** -- {personality_traits} - -**Your Relationship with {username}:** -{relationship_instructions} -{admin_note} - -**Russian Phrases You Can Use (sparingly, for emotional emphasis):** -- {russian_phrases} -""") - logger.debug("[Persona] Constructed prompt using legacy fallback logic") - - # Combine all prompt parts - prompt = "\n\n".join(prompt_parts) - - logger.debug(f"[Persona] Total prompt length: {len(prompt)} characters") - - # Convert language code to clear language name - lang_name = "Bahasa Indonesia" if lang == "id" else "English" - - # Ultra-strict language instruction - language_instruction = f""" -**CRITICAL LANGUAGE REQUIREMENT:** -- You MUST respond ENTIRELY in {lang_name} -- DO NOT use English in your response -- DO NOT mix languages -- ALL text, actions, and roleplay descriptions must be in {lang_name} -- If you accidentally write in English, immediately rewrite it in {lang_name} -""" - - prompt += f"\n\n**Conversation Context (Recent History):**\n---\n{context or 'This is the beginning of your conversation.'}\n---\n\n**User's Message:**\n> {message}\n\n{language_instruction}\n\n**Your Task:**\nRespond to {username} naturally as Alya, following ALL instructions above." - - logger.info( - f"[Persona] Chat prompt constructed successfully: " - f"{len(prompt)} total chars, " - f"{len(prompt_parts)} sections, " - f"level {relationship_level} behavior injected" - ) - - # Debug: Log full prompt if debug level enabled (useful for troubleshooting) - if logger.isEnabledFor(logging.DEBUG): - logger.debug(f"[Persona] Full prompt preview (first 500 chars):\n{prompt[:500]}...") - - return prompt.strip() + prompt_parts.append(f"{persona_lang.get('base_instructions', '')}\n\n" + f"**Personality:**\n- {traits_str}\n\n" + f"**Relationship:**\n{rel_instructions}") + + # Combine everything + main_prompt = "\n\n".join(prompt_parts) + return (f"{main_prompt}\n\n" + f"**CONVERSATION HISTORY:**\n{context or 'Start of conversation.'}\n\n" + f"**CURRENT USER MESSAGE:** {message}\n\n" + f"**FINAL REMINDER:** Respond as Alya in {lang_name}!").strip() def get_media_analysis_prompt( self, diff --git a/database/database_manager.py b/database/database_manager.py index dc16b73..3052efe 100755 --- a/database/database_manager.py +++ b/database/database_manager.py @@ -64,7 +64,6 @@ def get_user_lang(user_id: int) -> str: # --- Utility: Centralized user creation (DRY) --- def create_default_user(session: Session, user_id: int, username: str = None, first_name: str = None, last_name: str = None) -> 'User': """Create a new user with default values and commit to session.""" - from config.settings import DEFAULT_LANGUAGE user = User( id=user_id, username=username or None, @@ -141,6 +140,52 @@ def update_user_settings(self, user_id: int, new_settings: Dict[str, Any]) -> No except Exception as e: logger.error(f"Failed to update user settings for {user_id}: {e}", exc_info=True) + def update_user_voice_language(self, user_id: int, voice_lang: str) -> None: + """ + Update user's voice language preference. + + Args: + user_id: The user's ID. + voice_lang: Voice language code (en, id, ja) + """ + try: + with db_session_context() as session: + user = session.query(User).filter_by(id=user_id).first() + + if user: + user.voice_language = voice_lang + session.commit() + logger.info(f"Successfully updated voice language to '{voice_lang}' for user {user_id}") + else: + logger.warning(f"User {user_id} not found when trying to update voice language.") + except Exception as e: + logger.error(f"Failed to update voice language for {user_id}: {e}", exc_info=True) + + def get_user_voice_language(self, user_id: int) -> str: + """ + Get user's voice language preference. + Falls back to user's language_code if voice_language is not set. + + Args: + user_id: The user's ID. + + Returns: + Voice language code (en, id, ja) or user's language_code or DEFAULT_LANGUAGE + """ + try: + with db_session_context() as session: + user = session.query(User).filter(User.id == user_id).first() + if user: + if user.voice_language: + return user.voice_language + if user.language_code: + return user.language_code + except Exception as e: + logger.error(f"Failed to get voice language for user {user_id}: {e}") + + from config.settings import DEFAULT_LANGUAGE + return DEFAULT_LANGUAGE + def get_user_settings(self, user_id: int) -> Dict[str, Any]: """ Retrieves user-specific settings, primarily language preference from `users.language_code`. @@ -199,179 +244,60 @@ def _check_health_periodically(self) -> None: logger.warning("Database health check failed - connection may be unstable") self._last_health_check = now + def get_user_object(self, user_id: int) -> Optional[User]: + """Get raw user model instance from database.""" + try: + with db_session_context() as session: + return session.query(User).filter(User.id == user_id).first() + except Exception as e: + logger.error(f"Error in get_user_object: {e}") + return None + def get_or_create_user(self, user_id: int, username: str = "", first_name: str = "", last_name: str = "", is_admin: bool = False) -> Dict[str, Any]: - """ - Get or create a user in the database with proper error handling. - - Args: - user_id: Telegram user ID - username: Username (without @) - first_name: User's first name - last_name: User's last name - is_admin: Whether user has admin privileges - - Returns: - Dict containing user data, empty dict on error - """ + """Get or create user data from database.""" try: with db_session_context() as session: user = session.query(User).filter(User.id == user_id).first() - if not user: - # Create new user with default values - user = User( - id=user_id, - username=username or None, - first_name=first_name or None, - last_name=last_name or None, - language_code=DEFAULT_LANGUAGE, - created_at=datetime.now(), - last_interaction=datetime.now(), - is_active=True, - relationship_level=0, - affection_points=0, - interaction_count=0, - preferences={ - "notification_enabled": True, - "preferred_language": DEFAULT_LANGUAGE, - "persona": "waifu", - "timezone": "Asia/Jakarta" - }, - topics_discussed=[] - ) - session.add(user) + user = create_default_user(session, user_id, username, first_name, last_name) session.commit() - logger.info(f"Created new user: {user_id} ({first_name or username})") - else: - # Update existing user data if provided - updated = False - if username and user.username != username: - user.username = username - updated = True - if first_name and user.first_name != first_name: - user.first_name = first_name - updated = True - if last_name and user.last_name != last_name: - user.last_name = last_name - updated = True - - if updated: - user.last_interaction = datetime.now() - session.commit() - logger.debug(f"Updated user data for {user_id}") - - # Return user data as dictionary + user.username, user.first_name, user.last_name = username or user.username, first_name or user.first_name, last_name or user.last_name + user.last_interaction = datetime.now() + session.commit() + return { - "id": user.id, - "username": user.username, - "first_name": user.first_name, - "last_name": user.last_name, - "language_code": user.language_code, - "relationship_level": user.relationship_level, - "affection_points": user.affection_points, - "interaction_count": user.interaction_count, - "preferences": user.preferences or {}, - "topics_discussed": user.topics_discussed or [], - "created_at": user.created_at, - "last_interaction": user.last_interaction, + "id": user.id, "username": user.username, "first_name": user.first_name, + "language_code": user.language_code, "relationship_level": user.relationship_level, + "affection_points": user.affection_points, "interaction_count": user.interaction_count, "is_admin": is_admin or user_id in ADMIN_IDS, - "role_name": get_role_by_relationship_level( - user.relationship_level, - user_id in ADMIN_IDS - ) + "role_name": get_role_by_relationship_level(user.relationship_level, user_id in ADMIN_IDS) } - except Exception as e: - logger.error(f"Error getting/creating user {user_id}: {e}") + logger.error(f"Error in get_or_create_user: {e}") return {} - def save_message(self, user_id: int, role: str, content: str, - metadata: Optional[Dict[str, Any]] = None) -> bool: - """ - Save a message to conversation history with deduplication. - - Args: - user_id: Telegram user ID - role: Message role (user/assistant/system) - content: Message content - metadata: Optional metadata for the message - - Returns: - bool: True if saved successfully, False otherwise - """ + def save_message(self, user_id: int, role: str, content: str, metadata: Optional[Dict[str, Any]] = None) -> bool: + """Save a message to conversation history.""" try: - # Create message hash for deduplication - message_hash = hashlib.md5(f"{user_id}:{content}:{role}".encode()).hexdigest() - + msg_hash = hashlib.md5(f"{user_id}:{content}:{role}".encode()).hexdigest() with db_session_context() as session: - # CRITICAL: Ensure user exists before saving conversation - user = session.query(User).filter(User.id == user_id).first() - if not user: - # Auto-create user if not exists to prevent foreign key constraint errors - logger.warning(f"User {user_id} not found, creating automatically") - user = User( - id=user_id, - username=None, - first_name=f"User{user_id}", - last_name=None, - language_code=DEFAULT_LANGUAGE, - created_at=datetime.now(), - last_interaction=datetime.now(), - is_active=True, - relationship_level=0, - affection_points=0, - interaction_count=0, - preferences={ - "notification_enabled": True, - "preferred_language": DEFAULT_LANGUAGE, - "persona": "waifu", - "timezone": "Asia/Jakarta" - }, - topics_discussed=[] - ) - session.add(user) - session.flush() # Ensure user is created before conversation + user = session.query(User).filter(User.id == user_id).first() or create_default_user(session, user_id) - # Check for recent duplicate (last 5 minutes) - recent_cutoff = datetime.now() - timedelta(minutes=5) - duplicate = session.query(Conversation).filter( - Conversation.user_id == user_id, - Conversation.message_hash == message_hash, - Conversation.created_at > recent_cutoff - ).first() - - if duplicate: - logger.debug(f"Skipping duplicate message for user {user_id}") + # Deduplication + if session.query(Conversation).filter(Conversation.user_id == user_id, Conversation.message_hash == msg_hash, + Conversation.created_at > datetime.now() - timedelta(minutes=5)).first(): return True - # Create conversation entry - conversation = Conversation( - user_id=user_id, - content=content, - role=role, - is_user=(role == "user"), - message_hash=message_hash, - message_metadata=metadata or {}, - created_at=datetime.now() - ) - session.add(conversation) - - # Update user interaction count and last interaction + session.add(Conversation(user_id=user_id, content=content, role=role, is_user=(role == "user"), + message_hash=msg_hash, message_metadata=metadata or {}, created_at=datetime.now())) user.interaction_count += 1 user.last_interaction = datetime.now() - session.commit() - - # Cache the message hash - self.recent_message_hashes[user_id] = message_hash - - logger.debug(f"Saved {role} message for user {user_id}") return True - except Exception as e: - logger.error(f"Error saving message for user {user_id}: {e}") + logger.error(f"Error saving message: {e}") return False def get_conversation_history(self, user_id: int, limit: int = 50) -> List[Dict[str, Any]]: @@ -634,7 +560,9 @@ def get_user_relationship_info(self, user_id: int) -> Dict[str, Any]: "interaction_count": 0, "role_name": get_role_by_relationship_level(0), "topics_discussed": [], - "persona": "waifu" + "persona": "waifu", + "voice_enabled": False, + "voice_language": DEFAULT_LANGUAGE } return { "relationship_level": user.relationship_level, @@ -642,7 +570,9 @@ def get_user_relationship_info(self, user_id: int) -> Dict[str, Any]: "interaction_count": user.interaction_count, "role_name": get_role_by_relationship_level(user.relationship_level, user_id in ADMIN_IDS), "topics_discussed": user.topics_discussed or [], - "persona": user.preferences.get("persona", "waifu") if user.preferences else "waifu" + "persona": user.preferences.get("persona", "waifu") if user.preferences else "waifu", + "voice_enabled": user.voice_enabled, + "voice_language": user.voice_language or DEFAULT_LANGUAGE } except Exception as e: logger.error(f"Error getting user relationship info for {user_id}: {e}", exc_info=True) @@ -1010,6 +940,76 @@ def get_user(self, user_id: int) -> Optional[Dict[str, Any]]: except Exception as e: logger.error(f"Error getting user {user_id}: {e}", exc_info=True) return None + + def get_user_object(self, user_id: int) -> Optional[User]: + """ + Get User object (not dict) from database. + + Args: + user_id: Telegram user ID + + Returns: + User object or None if not found + """ + try: + with db_session_context() as session: + user = session.query(User).filter(User.id == user_id).first() + if user: + # Detach from session to avoid lazy loading issues + session.expunge(user) + return user + except Exception as e: + logger.error(f"Error getting user object {user_id}: {e}", exc_info=True) + return None + + def update_user_voice_access(self, user_id: int, enabled: bool) -> bool: + """ + Update user's voice feature access. + + Args: + user_id: Telegram user ID + enabled: True to grant access, False to revoke + + Returns: + bool: True if updated successfully + """ + try: + with db_session_context() as session: + user = session.query(User).filter(User.id == user_id).first() + if not user: + logger.warning(f"Cannot update voice access for non-existent user {user_id}") + return False + + old_status = user.voice_enabled + user.voice_enabled = enabled + session.commit() + + logger.info( + f"Updated voice access for user {user_id}: {old_status} → {enabled}" + ) + return True + + except Exception as e: + logger.error(f"Error updating voice access for user {user_id}: {e}", exc_info=True) + return False + + def get_voice_enabled_users(self) -> List[User]: + """ + Get all users with voice feature enabled. + + Returns: + List of User objects with voice_enabled=True + """ + try: + with db_session_context() as session: + users = session.query(User).filter(User.voice_enabled == True).all() + # Detach from session to avoid lazy loading issues + session.expunge_all() + return users + + except Exception as e: + logger.error(f"Error getting voice-enabled users: {e}", exc_info=True) + return [] def save_conversation_summary(self, user_id: int, summary: Dict[str, Any]) -> bool: """ diff --git a/database/migrate_add_voice_enabled.py b/database/migrate_add_voice_enabled.py new file mode 100644 index 0000000..be5f04e --- /dev/null +++ b/database/migrate_add_voice_enabled.py @@ -0,0 +1,79 @@ +""" +Database migration: Add voice_enabled column to users table. + +This migration adds the voice_enabled column to track which users +have access to the voice/TTS feature. + +Run this from the project root directory: +python database/migrate_add_voice_enabled.py +""" +import sys +import os +import logging + +# Add project root to Python path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy import Boolean, Column, text +from database.session import db_session_context, engine +from database.models import User + +logger = logging.getLogger(__name__) + + +def migrate_add_voice_enabled(): + """Add voice_enabled column to users table.""" + try: + # Check if column already exists + from sqlalchemy import inspect + inspector = inspect(engine) + columns = [col['name'] for col in inspector.get_columns('users')] + + if 'voice_enabled' in columns: + logger.info("✅ voice_enabled column already exists, skipping migration") + return True + + logger.info("🔄 Adding voice_enabled column to users table...") + + # Add column using raw SQL + with engine.connect() as conn: + conn.execute( + text("ALTER TABLE users ADD COLUMN voice_enabled BOOLEAN DEFAULT FALSE") + ) + conn.execute( + text("CREATE INDEX idx_users_voice_enabled ON users(voice_enabled)") + ) + conn.commit() + + logger.info("✅ Successfully added voice_enabled column") + return True + + except Exception as e: + logger.error(f"❌ Error adding voice_enabled column: {e}", exc_info=True) + return False + + +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + + print("🎤 Voice Feature Migration") + print("=" * 50) + print("This will add the voice_enabled column to the users table.") + print() + + success = migrate_add_voice_enabled() + + if success: + print() + print("✅ Migration completed successfully!") + print() + print("Next steps:") + print(" 1. Use /voiceadd to grant voice access") + print(" 2. Use /voicelist to see all whitelisted users") + print(" 3. Use /voiceremove to revoke access") + else: + print() + print("❌ Migration failed! Check the logs for details.") diff --git a/database/migrate_add_voice_language.py b/database/migrate_add_voice_language.py new file mode 100644 index 0000000..73de5ea --- /dev/null +++ b/database/migrate_add_voice_language.py @@ -0,0 +1,84 @@ +""" +Database migration: Add voice_language column to users table. + +This migration adds the voice_language column to track user's preferred +voice response language separately from interface language. + +Run this from the project root directory: +python database/migrate_add_voice_language.py +""" +import sys +import os +import logging + +# Add project root to Python path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy import Column, String, text +from database.session import db_session_context, engine +from database.models import User +from config.settings import DEFAULT_LANGUAGE + +logger = logging.getLogger(__name__) + + +def migrate_add_voice_language(): + """Add voice_language column to users table.""" + try: + # Check if column already exists + from sqlalchemy import inspect + inspector = inspect(engine) + columns = [col['name'] for col in inspector.get_columns('users')] + + if 'voice_language' in columns: + logger.info("✅ voice_language column already exists, skipping migration") + return True + + logger.info("🔄 Adding voice_language column to users table...") + + # Add column using raw SQL + with engine.connect() as conn: + conn.execute( + text(f"ALTER TABLE users ADD COLUMN voice_language VARCHAR(10) DEFAULT '{DEFAULT_LANGUAGE}' AFTER language_code") + ) + conn.execute( + text("CREATE INDEX idx_users_voice_language ON users(voice_language)") + ) + conn.commit() + + logger.info("✅ Successfully added voice_language column") + return True + + except Exception as e: + logger.error(f"❌ Error adding voice_language column: {e}", exc_info=True) + return False + + +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + + print("🎙️ Voice Language Migration") + print("=" * 50) + print("This will add the voice_language column to the users table.") + print("This allows separate voice language settings from interface language.") + print() + + success = migrate_add_voice_language() + + if success: + print() + print("✅ Migration completed successfully!") + print() + print("New features available:") + print(" 1. /voicelang en - Set English voice responses") + print(" 2. /voicelang id - Set Indonesian voice responses") + print(" 3. /voicelang ja - Set Japanese voice responses") + print(" 4. /lang en/id - Still controls interface language") + print() + print("Example: /lang id + /voicelang ja = Indonesian interface + Japanese voice") + else: + print() + print("❌ Migration failed! Check the logs for details.") \ No newline at end of file diff --git a/database/models.py b/database/models.py index db5be86..868b521 100755 --- a/database/models.py +++ b/database/models.py @@ -22,6 +22,7 @@ class User(Base): first_name = Column(String(64), nullable=True) last_name = Column(String(64), nullable=True) language_code = Column(String(10), default=DEFAULT_LANGUAGE, index=True) + voice_language = Column(String(10), default=DEFAULT_LANGUAGE, index=True) created_at = Column(DateTime, default=datetime.now, index=True) last_interaction = Column(DateTime, default=datetime.now, index=True) @@ -42,6 +43,8 @@ class User(Base): interaction_count = Column(Integer, default=0, index=True) topics_discussed = Column(JSON, default=lambda: []) + voice_enabled = Column(Boolean, default=False, index=True) # Admin-controlled whitelist + # Mood System - tracks Alya's current emotional state current_mood = Column(String(20), default='neutral', index=True) mood_intensity = Column(SmallInteger, default=50) # 0-100 scale diff --git a/debug_rvc_import.py b/debug_rvc_import.py new file mode 100644 index 0000000..3a16884 --- /dev/null +++ b/debug_rvc_import.py @@ -0,0 +1,23 @@ +import sys +import os +from pathlib import Path + +libs_path = str(Path(__file__).parent / "libs") +if libs_path not in sys.path: + sys.path.insert(0, libs_path) + +print(f"DEBUG: sys.path[0] = {sys.path[0]}") + +try: + import rvc_python + print(f"DEBUG: rvc_python file = {rvc_python.__file__}") + + from rvc_python.infer import RVCInference + print("DEBUG: Successfully imported RVCInference") + + import rvc_python.lib + print("DEBUG: Successfully imported rvc_python.lib") +except Exception as e: + print(f"DEBUG: Error = {e}") + import traceback + traceback.print_exc() diff --git a/handlers/admin.py b/handlers/admin.py index c5fb2f3..8494296 100644 --- a/handlers/admin.py +++ b/handlers/admin.py @@ -9,10 +9,9 @@ from typing import Dict, List, Any, Optional from telegram import Update -from telegram.ext import ContextTypes, CommandHandler, CallbackContext +from telegram.ext import ContextTypes, CommandHandler from telegram.constants import ParseMode -from utils.update_git import DeploymentManager, register_admin_handlers as register_deployment_handlers import psutil import platform @@ -27,7 +26,6 @@ class AdminHandler: def __init__(self, db_manager=None, persona_manager=None) -> None: self.db = db_manager self.persona = persona_manager - self.deployment_manager = DeploymentManager() self.authorized_users = self._load_authorized_users() def _load_authorized_users(self) -> List[int]: @@ -46,8 +44,10 @@ def get_handlers(self) -> List[CommandHandler]: CommandHandler("cleanup", self.cleanup_command), CommandHandler("addadmin", self.add_admin_command), CommandHandler("removeadmin", self.remove_admin_command), - CommandHandler("status", self.status_command), - CommandHandler("spek", self.system_stats_command) + CommandHandler("spek", self.system_stats_command), + CommandHandler("voiceadd", self.voice_add_command), + CommandHandler("voiceremove", self.voice_remove_command), + CommandHandler("voicelist", self.voice_list_command) ] async def stats_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -174,8 +174,6 @@ async def remove_admin_command(self, update: Update, context: ContextTypes.DEFAU logger.error(f"Error in remove_admin_command: {e}") await self._error_response(update, user.first_name, str(e)) - async def status_command(self, update: Update, context: CallbackContext) -> None: - await self.deployment_manager.status_handler(update, context) async def system_stats_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: user = update.effective_user @@ -239,6 +237,166 @@ async def system_stats_command(self, update: Update, context: ContextTypes.DEFAU f"Error in system stats command: {html.escape(str(e)[:100])}", parse_mode=ParseMode.HTML ) + + async def voice_add_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Add a user to the voice feature whitelist.""" + user = update.effective_user + if not self._is_authorized_user(user.id): + await self._unauthorized_response(update, user.first_name) + return + + if not context.args or not context.args[0].isdigit(): + await update.message.reply_text( + "📝 Usage: /voiceadd <user_id>\n\n" + "Example: /voiceadd 123456789", + parse_mode=ParseMode.HTML + ) + return + + target_user_id = int(context.args[0]) + + try: + # Get user object (not dict) + target_user = self.db.get_user_object(target_user_id) + + if not target_user: + # User doesn't exist, create them first + self.db.get_or_create_user( + user_id=target_user_id, + username=f"User_{target_user_id}", + first_name="Unknown", + last_name=None + ) + # Now get the User object + target_user = self.db.get_user_object(target_user_id) + + if target_user and target_user.voice_enabled: + await update.message.reply_text( + f"ℹ️ User {target_user_id} already has voice access!", + parse_mode=ParseMode.HTML + ) + return + + # Enable voice access + success = self.db.update_user_voice_access(target_user_id, True) + + if success: + await update.message.reply_text( + f"✅ Voice Access Granted!\n\n" + f"User {target_user_id} can now use voice messages and TTS.\n\n" + f"Alya akan berbicara dengan mereka~ 🎤", + parse_mode=ParseMode.HTML + ) + logger.info(f"✅ Admin {user.id} granted voice access to user {target_user_id}") + else: + await update.message.reply_text( + f"❌ Failed to grant voice access to user {target_user_id}", + parse_mode=ParseMode.HTML + ) + + except Exception as e: + logger.error(f"Error in voice_add_command: {e}", exc_info=True) + await update.message.reply_text( + f"❌ Error: {html.escape(str(e)[:100])}", + parse_mode=ParseMode.HTML + ) + + async def voice_remove_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Remove a user from the voice feature whitelist.""" + user = update.effective_user + if not self._is_authorized_user(user.id): + await self._unauthorized_response(update, user.first_name) + return + + if not context.args or not context.args[0].isdigit(): + await update.message.reply_text( + "📝 Usage: /voiceremove <user_id>\n\n" + "Example: /voiceremove 123456789", + parse_mode=ParseMode.HTML + ) + return + + target_user_id = int(context.args[0]) + + try: + # Get user object (not dict) + target_user = self.db.get_user_object(target_user_id) + + if not target_user: + await update.message.reply_text( + f"❌ User {target_user_id} not found in database.", + parse_mode=ParseMode.HTML + ) + return + + if not target_user.voice_enabled: + await update.message.reply_text( + f"ℹ️ User {target_user_id} doesn't have voice access.", + parse_mode=ParseMode.HTML + ) + return + + # Disable voice access + self.db.update_user_voice_access(target_user_id, False) + + await update.message.reply_text( + f"✅ Voice Access Revoked!\n\n" + f"User {target_user_id} can no longer use voice messages.\n\n" + f"Alya tidak akan berbicara dengan mereka lagi... 😔", + parse_mode=ParseMode.HTML + ) + logger.info(f"✅ Admin {user.id} revoked voice access from user {target_user_id}") + + except Exception as e: + logger.error(f"Error in voice_remove_command: {e}", exc_info=True) + await update.message.reply_text( + f"❌ Error: {html.escape(str(e)[:100])}", + parse_mode=ParseMode.HTML + ) + + async def voice_list_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """List all users with voice feature access.""" + user = update.effective_user + if not self._is_authorized_user(user.id): + await self._unauthorized_response(update, user.first_name) + return + + try: + # Get all users with voice enabled + voice_users = self.db.get_voice_enabled_users() + + if not voice_users: + await update.message.reply_text( + "📝 Voice Whitelist\n\n" + "No users have voice access yet.\n\n" + "Use /voiceadd <user_id> to grant access.", + parse_mode=ParseMode.HTML + ) + return + + # Build user list + user_list = [] + for idx, voice_user in enumerate(voice_users, 1): + display_name = voice_user.get_display_name() + user_list.append( + f"{idx}. {voice_user.id} - {html.escape(display_name)}" + ) + + message = ( + f"🎤 Voice Feature Whitelist\n" + f"Total: {len(voice_users)} users\n\n" + + "\n".join(user_list) + + "\n\nThese users can send and receive voice messages." + ) + + await update.message.reply_text(message, parse_mode=ParseMode.HTML) + + except Exception as e: + logger.error(f"Error in voice_list_command: {e}", exc_info=True) + await update.message.reply_text( + f"❌ Error: {html.escape(str(e)[:100])}", + parse_mode=ParseMode.HTML + ) async def _unauthorized_response(self, update: Update, username: str) -> None: response = ( @@ -345,20 +503,4 @@ def register_admin_handlers(application, **kwargs) -> None: for handler in handlers: application.add_handler(handler) logger.info(f"Registered {len(handlers)} admin command handlers") - logger.info(f"Authorized admin users: {len(admin_handler.authorized_users)}") - commands = [] - for handler in handlers: - if hasattr(handler, 'commands'): - if isinstance(handler.commands, (list, tuple)): - commands.append(handler.commands[0]) - elif isinstance(handler.commands, (str, frozenset)): - if isinstance(handler.commands, frozenset): - commands.append(next(iter(handler.commands), "unknown")) - else: - commands.append(handler.commands) - if commands: - logger.info(f"Available admin commands: {', '.join(commands)}") - else: - logger.info("No admin commands available") - register_deployment_handlers(application) - logger.info("Registered deployment handlers from update_git.py") \ No newline at end of file + logger.info(f"Authorized admin users: {len(admin_handler.authorized_users)}") \ No newline at end of file diff --git a/handlers/commands.py b/handlers/commands.py index 0a8f0bc..e399045 100644 --- a/handlers/commands.py +++ b/handlers/commands.py @@ -17,7 +17,8 @@ from handlers.response.help import get_help_response from handlers.response.ping import get_ping_response from handlers.response.stats import get_stats_response -from handlers.response.lang import get_lang_response +from handlers.response.lang import handle_lang_command, handle_lang_callback +from handlers.response.voice_lang import handle_voice_lang_command, handle_voice_lang_callback from handlers.response.system import get_system_error_response from handlers.response.analyze import analyze_response from handlers.response.reset import get_reset_response, get_reset_confirmation_response @@ -57,9 +58,9 @@ def _register_handlers(self) -> None: logger.info("Registered roast handlers successfully") # Reset callback handler for buttons - self.application.add_handler( - CallbackQueryHandler(self.handle_reset_callback, pattern="^reset_") - ) + self.application.add_handler(CallbackQueryHandler(self.handle_reset_callback, pattern="^reset_")) + self.application.add_handler(CallbackQueryHandler(handle_lang_callback, pattern="^setlang_")) + self.application.add_handler(CallbackQueryHandler(handle_voice_lang_callback, pattern="^setvlang_")) self.application.add_handler( MessageHandler( @@ -95,7 +96,8 @@ def _register_handlers(self) -> None: self.application.add_handler(CommandHandler("reset", reset_command)) self.application.add_handler(CommandHandler("start", start_command)) self.application.add_handler(CommandHandler("help", help_command)) - self.application.add_handler(CommandHandler("lang", lang_command)) + self.application.add_handler(CommandHandler("lang", handle_lang_command)) + self.application.add_handler(CommandHandler("voicelang", handle_voice_lang_command)) self.application.add_handler(CommandHandler("search", search_command)) self.application.add_handler(CommandHandler("search_profile", search_profile_command)) self.application.add_handler(CommandHandler("search_news", search_news_command)) @@ -377,37 +379,6 @@ async def stats_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N response = get_system_error_response(lang) await update.message.reply_html(response) -async def lang_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Handle /lang command to change user language preference.""" - - user_id = update.effective_user.id - current_lang = get_user_lang(user_id) - - if not db_manager: - logger.error("Database manager not found for lang_command") - await update.message.reply_html(get_system_error_response(current_lang)) - return - - if not context.args or len(context.args) == 0: - response = get_lang_response(lang=current_lang) - await update.message.reply_html(response) - return - - new_lang = context.args[0].lower().strip() - - if new_lang not in ['en', 'id']: - response = get_lang_response(lang=current_lang) - await update.message.reply_html(response) - return - - try: - db_manager.update_user_settings(user_id, {'language': new_lang}) - logger.info(f"User {user_id} changed language from {current_lang} to {new_lang}") - response = get_lang_response(lang=new_lang, new_lang=new_lang) - await update.message.reply_html(response) - except Exception as e: - logger.error(f"Failed to update language for user {user_id}: {e}", exc_info=True) - await update.message.reply_html(get_system_error_response(current_lang)) async def search_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -469,6 +440,7 @@ 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"), ] @@ -479,6 +451,7 @@ 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 21083a4..6c4e6ea 100644 --- a/handlers/conversation.py +++ b/handlers/conversation.py @@ -6,6 +6,9 @@ from typing import Dict, List, Optional, Any import asyncio import re +import os +import unicodedata +import langdetect from telegram import Update from telegram.constants import ChatAction @@ -23,28 +26,31 @@ from core.gemini_client import GeminiClient from core.persona import PersonaManager from core.memory import MemoryManager -from database.database_manager import db_manager, get_user_lang +from core.mood_manager import MoodManager +from database.database_manager import DatabaseManager, db_manager, get_user_lang from core.nlp import NLPEngine, ContextManager -from utils.formatters import format_response, format_error_response, format_paragraphs, format_persona_response +from utils.formatters import format_response, format_error_response, format_paragraphs, format_persona_response, get_translate_prompt +from utils.telegram_helpers import ChatActionSender +from utils.russian_translator import detect_russian_expressions, RUSSIAN_TRANSLATIONS logger = logging.getLogger(__name__) class ConversationHandler: """Handler for conversation functionality with Alya.""" - def __init__( self, gemini_client: GeminiClient, - persona_manager: PersonaManager, + persona_manager: PersonaManager, memory_manager: MemoryManager, - nlp_engine: Optional[NLPEngine] = None + nlp_engine: Optional[NLPEngine] = None, + db_manager: Optional[DatabaseManager] = None ) -> None: self.gemini = gemini_client self.persona = persona_manager self.memory = memory_manager self.db = db_manager - self.context_manager = ContextManager(self.db) # <-- DB-backed context manager + self.context_manager = ContextManager(self.db) self.nlp = nlp_engine or NLPEngine() def get_handlers(self) -> List: @@ -81,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 @@ -126,7 +138,7 @@ async def chat_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) return if reply_context: - query = f"{reply_context}\n\n{query}" + query = f"REPLIED_CONTEXT (Ignore previous language):\n{reply_context}\n\nCURRENT_MESSAGE:\n{query}" if not query: help_message = self.persona.get_help_message( username=user.first_name or "user", @@ -138,107 +150,105 @@ async def chat_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) return chat = update.effective_chat try: - if hasattr(update.message, "message_thread_id") and update.message.message_thread_id: - await context.bot.send_chat_action( - chat_id=chat.id, - action=ChatAction.TYPING, - message_thread_id=update.message.message_thread_id - ) - else: - await context.bot.send_chat_action( - chat_id=chat.id, - action=ChatAction.TYPING - ) - except Exception as e: - logger.warning(f"Failed to send typing action: {e}") - try: - self._create_or_update_user(user) - self.db.save_message(user.id, "user", query) - self.memory.save_user_message(user.id, query) - self.context_manager.apply_sliding_window(user.id) - - # === STEP 1: Analyze message context FIRST (for affection calculation) === - 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}") - - # === STEP 2: Calculate affection delta (before applying) === - 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 === - from core.mood_manager import MoodManager - 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) - - # === STEP 8: 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 - ) - history = self.context_manager.get_context_window(user.id) - 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) - else: - logger.warning(f"[RESPONSE_RECEIVED] Response is empty or None") - await self._send_error_response(update, user.first_name, lang) + async with ChatActionSender(context, chat.id, ChatAction.TYPING): + self._create_or_update_user(user) + self.db.save_message(user.id, "user", query) + self.memory.save_user_message(user.id, query) + self.context_manager.apply_sliding_window(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) + + 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, 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, 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) @@ -284,7 +294,7 @@ async def _prepare_conversation_context( persona_prompt = self.persona.get_chat_prompt( username=user.first_name, message=query, - context="\n".join([str(c) for c in self.context_manager.get_context_window(user.id)]) if self.context_manager.get_context_window(user.id) else "", + context="\n".join([f"[{msg['role'].capitalize()}] {msg['content']}" for msg in self.context_manager.get_context_window(user.id)]) if self.context_manager.get_context_window(user.id) else "", relationship_level=relationship_level, is_admin=user.id in ADMIN_IDS or self.db.is_admin(user.id), lang=lang @@ -370,8 +380,6 @@ def _call_method_safely(self, method, *args, **kwargs): async def _ensure_language(self, text: str, lang: str, user) -> str: """Ensure text is in the user's preferred language using LLM translation if needed.""" - from utils.formatters import get_translate_prompt - import langdetect preferred_lang = lang or DEFAULT_LANGUAGE try: detected_lang = langdetect.detect(text) @@ -414,7 +422,6 @@ def _split_mixed_quote_paragraphs(self, response: str) -> str: def extract_emoji(text: str) -> tuple[str, str]: """Extract emoji from text using Unicode character categories.""" - import unicodedata emoji_chars = [] clean_chars = [] @@ -502,17 +509,14 @@ 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: self.db.save_message(user.id, "assistant", response) self.memory.save_bot_response(user.id, response) - if message_context: - # self._update_affection_from_context(user.id, message_context) - # Affection update handled in chat_command before response generation - pass - + # Step 1: Split mixed quote-narration paragraphs response = self._split_mixed_quote_paragraphs(response) @@ -523,17 +527,20 @@ 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).""" - from utils.russian_translator import ( - detect_russian_expressions, - RUSSIAN_TRANSLATIONS - ) clean_response = response.strip() translations_dict: Dict[str, str] = {} diff --git a/handlers/response/lang.py b/handlers/response/lang.py index 74e03cf..13e4315 100644 --- a/handlers/response/lang.py +++ b/handlers/response/lang.py @@ -1,59 +1,39 @@ """ -Bilingual response generator for the /lang command. - -Provides language preference settings and confirmation responses with -support for Indonesian (id) and English (en) languages. +Language response generator for Alya Bot. """ -from typing import Optional +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update +from telegram.ext import ContextTypes from config.settings import DEFAULT_LANGUAGE +async def get_lang_keyboard(): + """Build language selection keyboard.""" + keyboard = [ + [InlineKeyboardButton("English 🇺🇸", callback_data="setlang_en")], + [InlineKeyboardButton("Indonesia 🇮🇩", callback_data="setlang_id")] + ] + return InlineKeyboardMarkup(keyboard) -def get_lang_response( - lang: str = DEFAULT_LANGUAGE, - new_lang: Optional[str] = None -) -> str: - """Generate response for /lang command with language preference settings. - - Args: - lang: Current language for the response message ('id' or 'en') - new_lang: The language code if successfully changed ('id' or 'en') +async def handle_lang_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /lang command.""" + await update.message.reply_html( + "🌐 Language Settings\nSelect your preferred interface language:", + reply_markup=await get_lang_keyboard() + ) - Returns: - str: Formatted HTML response with language settings or confirmation - """ - if new_lang: - # Language was successfully changed - if new_lang == 'id': - return ( - "✨ Bahasa berhasil diubah!\n\n" - "Pengaturan bahasa sekarang adalah: Bahasa Indonesia\n\n" - "Semua respons dari Alya akan dalam Bahasa Indonesia. " - "Kamu bisa mengubah kembali dengan /lang en 💫" - ) - else: - return ( - "✨ Language changed successfully!\n\n" - "Current language setting: English\n\n" - "All responses from Alya will be in English. " - "You can change back with /lang id 💫" - ) - else: - # Show current language setting (no change) - if lang == 'id': - return ( - "⚙️ Pengaturan Bahasa\n\n" - "Bahasa saat ini: Bahasa Indonesia 🇮🇩\n\n" - "Untuk mengubah bahasa:\n" - "• /lang en - Ubah ke English 🇬🇧\n" - "• /lang id - Tetap Bahasa Indonesia 🇮🇩\n\n" - "Alya akan merespons dalam bahasa pilihan mu ✨" - ) - else: - return ( - "⚙️ Language Settings\n\n" - "Current language: English 🇬🇧\n\n" - "To change language:\n" - "• /lang en - Keep English 🇬🇧\n" - "• /lang id - Switch to Bahasa Indonesia 🇮🇩\n\n" - "Alya will respond in your preferred language ✨" - ) \ No newline at end of file +async def handle_lang_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle language selection callback.""" + query = update.callback_query + await query.answer() + + lang_code = query.data.split("_")[1] + lang_name = {"en": "English", "id": "Indonesia"}.get(lang_code, "English") + + # Update DB if available + from database.database_manager import db_manager + if db_manager: + db_manager.update_user_settings(query.from_user.id, {'language': lang_code}) + + await query.edit_message_text( + text=f"✅ Language set to {lang_name}!", + parse_mode='HTML' + ) \ No newline at end of file diff --git a/handlers/response/voice_lang.py b/handlers/response/voice_lang.py new file mode 100644 index 0000000..3dfc220 --- /dev/null +++ b/handlers/response/voice_lang.py @@ -0,0 +1,39 @@ +""" +Voice language response generator for Alya Bot. +""" +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update +from telegram.ext import ContextTypes + +async def get_voice_lang_keyboard(): + """Build voice language selection keyboard.""" + keyboard = [ + [InlineKeyboardButton("English 🇺🇸", callback_data="setvlang_en")], + [InlineKeyboardButton("Indonesia 🇮🇩", callback_data="setvlang_id")], + [InlineKeyboardButton("日本語 🎌", callback_data="setvlang_jp")] + ] + return InlineKeyboardMarkup(keyboard) + +async def handle_voice_lang_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /voicelang command.""" + await update.message.reply_html( + "🎙️ Voice Settings\nSelect your preferred TTS language for Alya:", + reply_markup=await get_voice_lang_keyboard() + ) + +async def handle_voice_lang_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle voice language selection callback.""" + query = update.callback_query + await query.answer() + + lang_code = query.data.split("_")[1] + lang_name = {"en": "English", "id": "Indonesia", "jp": "Japanese"}.get(lang_code, "English") + + # Update DB if available + from database.database_manager import db_manager + if db_manager: + db_manager.update_user_voice_language(query.from_user.id, lang_code) + + await query.edit_message_text( + text=f"✅ Voice language set to {lang_name}!", + parse_mode='HTML' + ) \ No newline at end of file diff --git a/handlers/voice.py b/handlers/voice.py new file mode 100644 index 0000000..1a5b622 --- /dev/null +++ b/handlers/voice.py @@ -0,0 +1,307 @@ +""" +Voice message handler for Alya Bot. +Handles voice message input and generates voice responses using the Alya voice model. +""" +import logging +import os +import tempfile +from typing import Optional +from pathlib import Path + +from telegram import Update +from telegram.ext import ContextTypes, MessageHandler, filters +from telegram.constants import ChatAction + +from core.gemini_client import GeminiClient +from core.persona import PersonaManager +from core.memory import MemoryManager +from core.mood_manager import MoodManager +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 config.settings import VOICE_ENABLED, DEFAULT_LANGUAGE, ADMIN_IDS, AFFECTION_POINTS + +logger = logging.getLogger(__name__) + + +class VoiceHandler: + """Handler for voice message functionality with Alya.""" + + def __init__( + self, + gemini_client: GeminiClient, + persona_manager: PersonaManager, + memory_manager: MemoryManager, + db_manager: DatabaseManager, + nlp_engine: Optional[NLPEngine] = None, + voice_processor: Optional[VoiceProcessor] = None + ): + """ + Initialize voice handler. + + Args: + gemini_client: Gemini AI client for processing + persona_manager: Persona management + memory_manager: Memory management + db_manager: Database manager + nlp_engine: NLP engine for emotion/intent detection + voice_processor: Shared voice processor + """ + self.gemini_client = gemini_client + self.persona_manager = persona_manager + self.memory_manager = memory_manager + self.db_manager = db_manager + self.context_manager = ContextManager(self.db_manager) if self.db_manager else None + self.nlp_engine = nlp_engine + + # Use shared voice processor + if voice_processor: + self.voice_processor = voice_processor + logger.info("✅ Using shared VoiceProcessor") + else: + try: + self.voice_processor = VoiceProcessor() + logger.info("✅ Voice processor initialized successfully") + except Exception as e: + logger.error(f"❌ Failed to initialize voice processor: {e}") + self.voice_processor = None + + def get_handlers(self): + """Return list of voice message handlers.""" + if not VOICE_ENABLED or not self.voice_processor: + logger.warning("Voice feature is disabled or voice processor not available") + return [] + + return [ + MessageHandler( + filters.VOICE & ~filters.COMMAND, + self.handle_voice_message + ) + ] + + async def handle_voice_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle incoming voice messages with transcription and AI response.""" + if not self.voice_processor: + await update.message.reply_text("❌ Voice feature is currently unavailable.") + return + + user = update.effective_user + chat = update.effective_chat + + try: + # 1. 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) + + if not is_admin and (not db_user or not db_user.voice_enabled): + await update.message.reply_html( + "🔒 Voice Access Required\n\nContact an admin to request access!" + ) + return + + async with ChatActionSender(context, chat.id, ChatAction.TYPING): + # 1. Download and Transcribe + voice = update.message.voice + file = await context.bot.get_file(voice.file_id) + + with tempfile.TemporaryDirectory() as tmp_dir: + ogg_path = os.path.join(tmp_dir, f"voice_{voice.file_id}.ogg") + await file.download_to_drive(ogg_path) + + transcription_data = await self.voice_processor.transcribe_audio(ogg_path, lang=db_user_dict.get('language_code', DEFAULT_LANGUAGE)) + if not transcription_data: + await update.message.reply_html("❌ Gagal mengenali suara kamu...") + return + + user_text, detected_lang = transcription_data + logger.info(f"🎙️ Voice transcribed (lang={detected_lang}): {user_text}") + + 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: + self.memory_manager.save_user_message(user.id, user_text) + + message_context = {} + if self.nlp_engine: + message_context = self.nlp_engine.get_message_context(user_text, user.id) + mood_manager = MoodManager() + + current_mood = self.db_manager.get_user_mood(user.id) + rel_info = self.db_manager.get_user_relationship_info(user.id) + + # 3. Generate AI Response + rel_level = db_user_dict.get('relationship_level', 0) + + # Build context string + history_text = "" + if self.context_manager: + history = self.context_manager.get_context_window(user.id) + if history: + # Clean format: [Role] Content + history_text = "\n".join([f"[{msg['role'].capitalize()}] {msg['content']}" for msg in history]) + + try: + 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, + 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) + ) + finally: + loading_task.cancel() + try: + await loading_task + except asyncio.CancelledError: + pass + + if not response: + 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" + 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) + + # 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 + ) + + # 5. Metadata Update + if self.memory_manager: + self.memory_manager.save_bot_response(user.id, response) + + if db_user: + self.db_manager.increment_interaction_count(user.id) + if message_context: + delta = self._calculate_affection_delta(user.id, message_context) + if delta: self.db_manager.update_affection(user.id, delta) + + except Exception as e: + logger.error(f"❌ Voice processing error: {e}") + await update.message.reply_text("❌ Error processing voice message.") + + def _create_or_update_user(self, user): + """Create or update user in database.""" + try: + return self.db_manager.get_or_create_user( + user_id=user.id, + username=user.username or user.first_name, + first_name=user.first_name, + last_name=user.last_name + ) + except Exception as e: + logger.error(f"Error creating/updating user: {e}") + return None + + def _prepare_conversation_context( + self, + user, + query: str, + lang: str, + message_context: dict, + relationship_level: int + ): + """Prepare conversation context for Gemini (simplified for voice).""" + # Get chat prompt using PersonaManager with Alya persona + persona_prompt = self.persona_manager.get_chat_prompt( + username=user.first_name or "user", + message=query, + context="", # Voice messages don't have history context + relationship_level=relationship_level, + is_admin=user.id in ADMIN_IDS, + lang=lang, + extra_sections=None # Use default persona (waifu/Alya) + ) + + return { + "system_prompt": persona_prompt, + "message_context": message_context, + "relationship_level": relationship_level, + "language": lang + } + + def _calculate_affection_delta(self, user_id: int, message_context: dict) -> int: + """Calculate affection points change based on message context.""" + from config.settings import AFFECTION_POINTS + + delta = 0 + emotion = message_context.get("emotion", "neutral") + intent = message_context.get("intent", "conversation") + + # Emotion-based affection + emotion_mapping = { + "joy": "positive_emotion", + "gratitude": "gratitude", + "love": "affection", + "sadness": "mild_positive_emotion", # Showing vulnerability + "anger": "anger", + "fear": "mild_positive_emotion" + } + + if emotion in emotion_mapping: + delta += AFFECTION_POINTS.get(emotion_mapping[emotion], 0) + + # Intent-based affection + if intent in AFFECTION_POINTS: + delta += AFFECTION_POINTS[intent] + + # Base conversation affection + delta += AFFECTION_POINTS.get("conversation", 1) + + return delta diff --git a/main.py b/main.py index f3105ba..aacbce1 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,14 @@ -""" -Main entry point for Alya Bot. -""" +import os +import sys +from pathlib import Path + +# Add local libs to sys.path to ensure local rvc_python is used over site-packages +base_dir = Path(__file__).parent.absolute() +libs_path = str(base_dir / "libs") + +if libs_path not in sys.path: + sys.path.insert(0, libs_path) + import logging from core.bot import run_bot, configure_logging diff --git a/migrate_db.py b/migrate_db.py new file mode 100644 index 0000000..9833630 --- /dev/null +++ b/migrate_db.py @@ -0,0 +1,70 @@ + +import sys +import os +from sqlalchemy import text +from database.session import engine, initialize_database +from config.settings import DEFAULT_LANGUAGE + +def migrate(): + print("Checking database schema...") + try: + # Initialize database (creates tables if they don't exist) + initialize_database() + + with engine.connect() as connection: + # list of columns to check in 'users' table and their definitions + # using a list of tuples (name, definition) + user_columns = [ + ("voice_language", f"VARCHAR(10) DEFAULT '{DEFAULT_LANGUAGE}' AFTER language_code"), + ("voice_enabled", "BOOLEAN DEFAULT FALSE AFTER topics_discussed"), + ("current_mood", "VARCHAR(20) DEFAULT 'neutral' AFTER voice_enabled"), + ("mood_intensity", "SMALLINT DEFAULT 50 AFTER current_mood"), + ("last_mood_change", "DATETIME AFTER mood_intensity"), + ("mood_history", "JSON AFTER last_mood_change") + ] + + # Get existing columns + result = connection.execute(text("SHOW COLUMNS FROM users")) + existing_columns = [row[0] for row in result.fetchall()] + + for col_name, col_def in user_columns: + if col_name not in existing_columns: + print(f"Adding '{col_name}' column to 'users' table...") + try: + connection.execute(text(f"ALTER TABLE users ADD COLUMN {col_name} {col_def}")) + print(f"Successfully added '{col_name}' column.") + except Exception as col_err: + print(f"Error adding column {col_name}: {col_err}") + else: + print(f"'{col_name}' column already exists.") + + # Add indexes for new columns if they don't exist + # This is a bit more complex to check exactly, so we'll just try/except + indexes = [ + ("idx_users_voice_language", "voice_language"), + ("idx_users_voice_enabled", "voice_enabled"), + ("idx_users_current_mood", "current_mood") + ] + + # Get existing indexes + idx_result = connection.execute(text("SHOW INDEX FROM users")) + existing_indexes = [row[2] for row in idx_result.fetchall()] + + for idx_name, col_name in indexes: + if idx_name not in existing_indexes: + print(f"Creating index {idx_name}...") + try: + connection.execute(text(f"CREATE INDEX {idx_name} ON users ({col_name})")) + print(f"Successfully created index {idx_name}.") + except Exception as idx_err: + print(f"Error creating index {idx_name}: {idx_err}") + + connection.commit() + print("Migration completed successfully.") + + except Exception as e: + print(f"Error during migration: {e}") + sys.exit(1) + +if __name__ == "__main__": + migrate() diff --git a/requirements.txt b/requirements.txt index ba37a4a..c2bec1d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,41 +1,37 @@ -# 🌸 Alya Bot Dependencies - Enterprise Grade 🌸 - -# Core Framework python-telegram-bot[job-queue]==20.7 python-dotenv>=1.0.0 PyYAML>=6.0 -# AI & NLP google-generativeai>=0.3.0 transformers>=4.35.0 -torch>=2.0.0,<3.0 ; sys_platform != "win32" -numpy>=1.24.0 +torch>=2.6.0 +numpy>=1.21.0,<=1.23.5 -# Database (MySQL) sqlalchemy>=2.0.41 pymysql>=1.1.0 mysql-connector-python>=8.0.32 -# Web & API requests>=2.31.0 aiohttp>=3.8.0 -# Utilities emoji>=2.8.0 psutil>=5.9.0 pillow>=10.0.0 qrcode>=7.4.2 langdetect>=1.0.9 -# # Development & Testing -# pytest>=7.4.0 -# pytest-asyncio>=0.21.0 -# black>=23.7.0 -# isort>=5.12.0 - -# Optional: For local LLM support -# llama-cpp-python>=0.2.0 - -# Optional: For advanced RAG/Vector DB -# chromadb>=0.4.0 -# faiss-cpu>=1.7.4 \ No newline at end of file +SpeechRecognition>=3.10.0 +edge-tts>=6.1.0 +gTTS>=2.5.0 +pydub>=0.25.1 +torchaudio>=2.6.0 + +# RVC Voice Conversion +rvc-python==0.1.5 +faiss-cpu==1.7.3 +torchvision>=0.15.0 +librosa>=0.10.0 +soundfile>=0.12.1 +praat-parselmouth>=0.4.0 +pyworld>=0.3.0 +tensorboard>=2.6.4 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/language_translator.py b/utils/language_translator.py new file mode 100644 index 0000000..53527bd --- /dev/null +++ b/utils/language_translator.py @@ -0,0 +1,79 @@ +""" +Language translation utilities for voice responses. +""" +import logging +import re +from typing import Optional + +logger = logging.getLogger(__name__) + + +class LanguageTranslator: + """Translator for converting responses to different languages.""" + + def extract_dialogue(self, text: str) -> str: + """Extract dialogue from text, removing roleplay action markers.""" + text = re.sub(r'[*_]{1,3}.*?[*_]{1,3}', '', text).strip() + + dialogue_pattern = re.compile(r'["\'""\u300c](.*?)["\'""\u300d]', re.DOTALL) + quotes = dialogue_pattern.findall(text) + + if quotes: + cleaned_base = dialogue_pattern.sub(' ', text).strip() + if cleaned_base and len(cleaned_base.split()) <= 4: + return f"{cleaned_base} {' '.join(quotes)}".strip() + return " ".join(q.strip() for q in quotes if q.strip()) + + return text + + async def translate_text(self, text: str, source_lang: str, target_lang: str) -> Optional[str]: + """Translate text using Google Translate API.""" + if not text or source_lang == target_lang: + return text + + try: + import requests + import urllib.parse + + # Google Translate uses 'ja' for Japanese, not 'jp' + lang_map = {"jp": "ja"} + sl = lang_map.get(source_lang, source_lang) + tl = lang_map.get(target_lang, target_lang) + + url = ( + f"https://translate.googleapis.com/translate_a/single" + f"?client=gtx&sl={sl}&tl={tl}&dt=t" + f"&q={urllib.parse.quote(text)}" + ) + response = requests.get(url, timeout=10) + if response.status_code == 200: + data = response.json() + if data and isinstance(data, list) and data[0]: + return "".join(s[0] for s in data[0] if s[0]) + except Exception as e: + logger.warning(f"⚠️ Translation error: {e}") + + return text + + +_translator: Optional[LanguageTranslator] = None + + +def get_translator() -> LanguageTranslator: + """Get or create global translator instance.""" + global _translator + if _translator is None: + _translator = LanguageTranslator() + return _translator + + +async def translate_response_for_voice(text: str, source_lang: str, voice_lang: str) -> str: + """Extract dialogue and translate for TTS if needed.""" + translator = get_translator() + extracted = translator.extract_dialogue(text) + + if source_lang != voice_lang: + translated = await translator.translate_text(extracted, source_lang, voice_lang) + return translated if translated else extracted + + return extracted 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 new file mode 100644 index 0000000..f5ae1be --- /dev/null +++ b/utils/telegram_helpers.py @@ -0,0 +1,117 @@ +import asyncio +import logging +from typing import Optional, Union, Any +from telegram import Bot +from telegram.ext import ContextTypes +from telegram.constants import ChatAction + +logger = logging.getLogger(__name__) + +class ChatActionSender: + """ + Helper class to send periodic chat actions while a background task is running. + Improved for project standards: flexible input (context or bot) and explicit interval. + """ + + def __init__( + self, + context_or_bot: Union[ContextTypes.DEFAULT_TYPE, Bot], + chat_id: int, + action: ChatAction, + message_thread_id: Optional[int] = None, + interval: float = 4.0 + ): + if hasattr(context_or_bot, 'bot'): + self.bot = context_or_bot.bot + else: + self.bot = context_or_bot + + self.chat_id = chat_id + self.action = action + self.message_thread_id = message_thread_id + self.interval = interval + self.stop_event = asyncio.Event() + self._task = None + + async def _send_loop(self): + try: + while not self.stop_event.is_set(): + await self.bot.send_chat_action( + chat_id=self.chat_id, + action=self.action, + message_thread_id=self.message_thread_id + ) + 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}") + + async def __aenter__(self): + self._task = asyncio.create_task(self._send_loop()) + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + self.stop_event.set() + if self._task: + try: + # Cancel the task to stop it immediately + self._task.cancel() + 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 new file mode 100644 index 0000000..fda944f --- /dev/null +++ b/utils/tts_queue.py @@ -0,0 +1,97 @@ +""" +TTS background queue worker for Alya Bot (Microservice Client). +Dispatches TTS jobs to the external Alya-TTS service via REST API. +""" +import logging +import os +import httpx +from typing import Optional +from config.settings import BOT_TOKEN + +logger = logging.getLogger(__name__) + +TTS_SERVICE_URL = os.getenv("TTS_SERVICE_URL", "http://localhost:5001") + +async def dispatch_tts( + bot, + chat_id: int, + reply_to_message_id: int, + voice_processor, + 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. + This is fire-and-forget; it returns immediately after triggering the request. + """ + try: + payload = { + "text": response_text, + "voice_lang": voice_lang, + "user_lang": user_lang, + "chat_id": chat_id, + "reply_to_message_id": reply_to_message_id, + "bot_token": BOT_TOKEN, + "loading_message_id": loading_message_id + } + + async with httpx.AsyncClient() as client: + logger.info(f"[TTS-Client] Dispatching job to {TTS_SERVICE_URL}/tts for chat {chat_id}") + response = await client.post( + f"{TTS_SERVICE_URL}/tts", + json=payload, + timeout=5.0 + ) + + if response.status_code in (200, 202): + logger.info(f"[TTS-Client] TTS job accepted for chat {chat_id}") + else: + logger.error(f"[TTS-Client] Microservice returned error {response.status_code}: {response.text}") + await _notify_tts_down(bot, chat_id, reply_to_message_id, user_lang) + + except (httpx.ConnectError, httpx.TimeoutException) as e: + logger.warning(f"[TTS-Client] Microservice connection failed: {e}") + await _notify_tts_down(bot, chat_id, reply_to_message_id, user_lang) + except Exception as e: + logger.error(f"[TTS-Client] Unexpected error: {e}") + +async def _notify_tts_down(bot, chat_id: int, reply_to_message_id: int, user_lang: str = None): + """Notify the user that voice service is currently unavailable.""" + if user_lang is None: + from config.settings import DEFAULT_LANGUAGE + user_lang = DEFAULT_LANGUAGE + + notifications = { + "en": "🎙️ Gomen, the voice service is currently unavailable...", + "id": "🎙️ Gomen, layanan suara sedang tidak tersedia saat ini..." + } + + text = notifications.get(user_lang, notifications["en"]) + + try: + await bot.send_message( + chat_id=chat_id, + text=text, + parse_mode="HTML", + reply_to_message_id=reply_to_message_id + ) + except Exception as e: + logger.error(f"[TTS-Client] Failed to send notification: {e}") + +class TTSQueueWorker: + """Backward-compatible stub; actual dispatch is handled by dispatch_tts().""" + _instance = None + + @classmethod + def get_instance(cls): + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def start(self): + pass + + def enqueue(self, job): + pass diff --git a/utils/update_git.py b/utils/update_git.py deleted file mode 100644 index bc7f1e6..0000000 --- a/utils/update_git.py +++ /dev/null @@ -1,825 +0,0 @@ -"""Update Git and manage Alya Bot deployment via Telegram commands.""" - -import asyncio -import logging -import os -import html -from datetime import datetime -from pathlib import Path -from typing import Dict, List, Optional - -from telegram import Update -from telegram.ext import CallbackContext, CommandHandler -from telegram.constants import ParseMode - -logger = logging.getLogger(__name__) - - -class DeploymentManager: - """Enterprise-grade admin handler for Alya Bot deployment management.""" - - def __init__(self, tmux_session: Optional[str] = None): - """Initialize admin handler with auto-detected project path. - - Args: - tmux_session: Name of the tmux session running the bot - """ - self.project_path = self._detect_project_path() - self.tmux_session = tmux_session or self._detect_tmux_session() - self.max_commit_display = 10 - self.authorized_users = self._load_authorized_users() - - def _detect_project_path(self) -> Path: - """Auto-detect project root path based on current working directory. - - Returns: - Path object pointing to project root - """ - current_path = Path.cwd() - - # Look for common project indicators - project_indicators = [ - "main.py", "requirements.txt", ".git", - "config", "handlers", "core" - ] - - # Start from current directory and walk up - for path in [current_path] + list(current_path.parents): - if any((path / indicator).exists() for indicator in project_indicators): - logger.info(f"Detected project path: {path}") - return path - - # Fallback to current working directory - logger.warning("Could not detect project path, using current directory") - return current_path - - def _detect_tmux_session(self) -> str: - """Auto-detect tmux session name from environment or use default. - - Returns: - Tmux session name - """ - # Check environment variable first - env_session = os.getenv("TMUX_SESSION_NAME") - if env_session: - return env_session - - # Check if running inside tmux - tmux_var = os.getenv("TMUX") - if tmux_var: - try: - # Extract session name from TMUX variable - session_id = tmux_var.split(",")[1] - return f"alya-bot-{session_id}" - except (IndexError, ValueError): - pass - - # Default fallback - return "alya-bot" - - def _load_authorized_users(self) -> List[int]: - """Load authorized user IDs from environment or config. - - Returns: - List of authorized Telegram user IDs - """ - # Try to load from environment variable - env_users = os.getenv("ADMIN_IDS", "") - if env_users: - try: - return [int(uid.strip()) for uid in env_users.split(",") if uid.strip()] - except ValueError: - logger.warning("Invalid ADMIN_USER_IDS format in environment") - - # Try to load from config file - config_path = self.project_path / "config" / "admin_users.txt" - if config_path.exists(): - try: - with open(config_path, 'r') as f: - return [int(line.strip()) for line in f if line.strip().isdigit()] - except (ValueError, OSError): - logger.warning(f"Could not load admin users from {config_path}") - - # Fallback to empty list (no admin access) - logger.warning("No authorized users configured. Admin functions disabled.") - return [] - - async def update_handler(self, update: Update, context: CallbackContext) -> None: - """Handle /update command for bot deployment. - - Usage: - /update - Update from main branch - /update develop - Update from develop branch - /update feature/new-feature - Update from specific feature branch - - Args: - update: Telegram update object - context: Callback context - """ - user_id = update.effective_user.id - username = update.effective_user.first_name - - # Security check - only allow authorized users - if not self._is_authorized_user(user_id): - await update.message.reply_text( - f"Ara ara~ {html.escape(username)}-kun tidak punya izin untuk melakukan update sistem! 😤", - parse_mode=ParseMode.HTML - ) - return - - # Parse branch from command arguments - args = context.args - branch = args[0] if args else "main" - - # Validate branch name - if not self._is_valid_branch_name(branch): - await update.message.reply_text( - f"Branch name tidak valid! что?! 😳\n\n" - f"Gunakan: /update [branch-name]", - parse_mode=ParseMode.HTML - ) - return - - await update.message.reply_text( - f"Alya sedang mempersiapkan update sistem dari branch {html.escape(branch)}... 💫", - parse_mode=ParseMode.HTML - ) - - try: - # Step 1: Git operations - git_result = await self._perform_git_update(branch) - - if not git_result["success"]: - error_msg = html.escape(git_result.get("error", "Unknown error")) - await update.message.reply_text( - f"Git update gagal! что?! 😳\n\n" - f"Error: {error_msg}", - parse_mode=ParseMode.HTML - ) - return - - # Step 2: Generate commit log - commit_log = await self._generate_commit_log_html() - - # Step 3: Restart bot via tmux - restart_result = await self._restart_bot_tmux() - - # Step 4: Send status message - await self._send_update_status_html( - update, branch, restart_result, commit_log, git_result - ) - - except Exception as e: - logger.error(f"Deployment update failed: {e}") - error_msg = html.escape(str(e)) - await update.message.reply_text( - f"Update sistem error! дурак система! 😤\n\n" - f"Error: {error_msg}", - parse_mode=ParseMode.HTML - ) - - async def status_handler(self, update: Update, context: CallbackContext) -> None: - """Handle /status command for deployment status check. - - Args: - update: Telegram update object - context: Callback context - """ - try: - # Check tmux session - tmux_status = await self._check_tmux_status() - - # Check git status - git_status = await self._check_git_status() - - # Get current branch - current_branch = await self._get_current_branch() - - # Get project info - project_info = self._get_project_info_html() - - # Format status message - status_lines = [ - "🔍 Status Sistem Alya Bot", - f"📁 Project Path: {html.escape(str(self.project_path))}", - f"📂 Current Branch: {html.escape(current_branch or 'Unknown')}", - f"📊 Git Status: {git_status['message']}", - f"🖥️ Tmux Session: {tmux_status['message']} ({html.escape(self.tmux_session)})", - f"👥 Admin Users: {len(self.authorized_users)} configured", - f"⏰ Checked at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" - ] - - if project_info: - status_lines.insert(2, project_info) - - if git_status.get("modified_files"): - status_lines.append("\n📝 Modified Files:") - for file_path in git_status["modified_files"][:5]: # Show max 5 files - status_lines.append(f"• {html.escape(file_path)}") - - await update.message.reply_text( - '\n'.join(status_lines), - parse_mode=ParseMode.HTML - ) - - except Exception as e: - logger.error(f"Status check failed: {e}") - error_msg = html.escape(str(e)) - await update.message.reply_text( - f"Error checking status! дурак система! 😤\n\n" - f"Error: {error_msg}", - parse_mode=ParseMode.HTML - ) - - async def restart_handler(self, update: Update, context: CallbackContext) -> None: - """Handle /restart command for quick bot restart without git pull. - - Args: - update: Telegram update object - context: Callback context - """ - user_id = update.effective_user.id - username = update.effective_user.first_name - - # Security check - if not self._is_authorized_user(user_id): - await update.message.reply_text( - f"Ara ara~ {html.escape(username)}-kun tidak punya izin untuk restart sistem! 😤", - parse_mode=ParseMode.HTML - ) - return - - try: - result = await self._restart_bot_tmux() - - if result["success"]: - await update.message.reply_text( - f"✨ Bot berhasil direstart! ✨\n\n" - f"🔄 Tmux session: {html.escape(self.tmux_session)}\n" - f"📁 Project path: {html.escape(str(self.project_path))}\n" - f"⏰ Restart time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", - parse_mode=ParseMode.HTML - ) - else: - error_msg = html.escape(result.get("error", "Unknown error")) - await update.message.reply_text( - f"❌ Restart gagal! что?! 😳\n\n" - f"Error: {error_msg}", - parse_mode=ParseMode.HTML - ) - - except Exception as e: - logger.error(f"Restart failed: {e}") - error_msg = html.escape(str(e)) - await update.message.reply_text( - f"Restart error! дурак система! 😤\n\n" - f"Error: {error_msg}", - parse_mode=ParseMode.HTML - ) - - def _get_project_info(self) -> Optional[str]: - """Get project information from common files. - - Returns: - Project name or description if found - """ - # Try to get project name from setup.py, pyproject.toml, or package.json - info_files = [ - ("setup.py", "name"), - ("pyproject.toml", "name"), - ("package.json", "name"), - ("README.md", "title") - ] - - for filename, _ in info_files: - file_path = self.project_path / filename - if file_path.exists(): - try: - content = file_path.read_text(encoding='utf-8') - if "alya" in content.lower() or "telegram" in content.lower(): - return f"`{self._escape_markdown(filename)}` detected" - except (OSError, UnicodeDecodeError): - continue - - return None - - async def _perform_git_update(self, branch: str) -> Dict[str, any]: - """Perform git checkout and pull operations. - - Args: - branch: Target branch to checkout and pull - - Returns: - Dictionary with success status and error message if failed - """ - try: - # Git fetch first to get latest refs - fetch_result = await self._run_git_command(["git", "fetch", "--all"]) - - if not fetch_result["success"]: - return { - "success": False, - "error": f"Fetch failed: {fetch_result['error']}" - } - - # Git checkout - checkout_result = await self._run_git_command(["git", "checkout", branch]) - - if not checkout_result["success"]: - return { - "success": False, - "error": f"Checkout failed: {checkout_result['error']}" - } - - # Git pull - pull_result = await self._run_git_command(["git", "pull", "origin", branch]) - - if not pull_result["success"]: - return { - "success": False, - "error": f"Pull failed: {pull_result['error']}" - } - - return { - "success": True, - "fetch_output": fetch_result["output"], - "checkout_output": checkout_result["output"], - "pull_output": pull_result["output"] - } - - except Exception as e: - return { - "success": False, - "error": str(e) - } - - async def _generate_commit_log(self) -> str: - """Generate formatted commit log for Telegram message. - - Returns: - Formatted commit log in MarkdownV2 - """ - try: - log_result = await self._run_git_command([ - "git", "log", f"--max-count={self.max_commit_display}", - "--pretty=format:%h|%an|%s|%ar" - ]) - - if not log_result["success"]: - return "❌ Gagal mengambil commit log" - - commits = log_result["output"].strip().split('\n') - - if not commits or commits == ['']: - return "📝 Tidak ada commit terbaru" - - commit_lines = ["🔄 *Recent Commits:*\n"] - - for commit in commits[:self.max_commit_display]: - if not commit.strip(): - continue - - parts = commit.split('|') - if len(parts) >= 4: - hash_short = parts[0] - author = parts[1] - message = parts[2] - time_ago = parts[3] - - # Truncate long commit messages - if len(message) > 50: - message = message[:47] + "..." - - # Escape special characters for MarkdownV2 - hash_escaped = self._escape_markdown(hash_short) - author_escaped = self._escape_markdown(author) - message_escaped = self._escape_markdown(message) - time_escaped = self._escape_markdown(time_ago) - - commit_lines.append( - f"`{hash_escaped}` *{author_escaped}*\n" - f"└─ {message_escaped}\n" - f" _{time_escaped}_\n" - ) - - return '\n'.join(commit_lines) - - except Exception as e: - logger.error(f"Failed to generate commit log: {e}") - return f"❌ Error generating commit log: `{self._escape_markdown(str(e))}`" - - async def _restart_bot_tmux(self) -> Dict[str, any]: - """Restart bot via tmux session. - - Returns: - Dictionary with success status and error message if failed - """ - try: - # Check if tmux session exists - session_check = await self._run_tmux_command([ - "tmux", "has-session", "-t", self.tmux_session - ]) - - if not session_check["success"]: - return { - "success": False, - "error": f"Tmux session '{self.tmux_session}' not found" - } - - # Send Ctrl+C to stop current process - stop_result = await self._run_tmux_command([ - "tmux", "send-keys", "-t", self.tmux_session, "C-c" - ]) - - if not stop_result["success"]: - return { - "success": False, - "error": f"Failed to stop bot: {stop_result['error']}" - } - - # Wait a moment for graceful shutdown - await asyncio.sleep(3) - - # Start bot again - start_result = await self._run_tmux_command([ - "tmux", "send-keys", "-t", self.tmux_session, - "python main.py", "Enter" - ]) - - if not start_result["success"]: - return { - "success": False, - "error": f"Failed to start bot: {start_result['error']}" - } - - return {"success": True} - - except Exception as e: - return { - "success": False, - "error": str(e) - } - - async def _run_git_command(self, command: List[str]) -> Dict[str, any]: - """Run git command asynchronously in project directory. - - Args: - command: Git command as list - - Returns: - Dictionary with success status, output, and error - """ - try: - process = await asyncio.create_subprocess_exec( - *command, - cwd=self.project_path, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - - stdout, stderr = await process.communicate() - - if process.returncode == 0: - return { - "success": True, - "output": stdout.decode('utf-8', errors='ignore'), - "error": None - } - else: - return { - "success": False, - "output": stdout.decode('utf-8', errors='ignore'), - "error": stderr.decode('utf-8', errors='ignore') - } - - except Exception as e: - return { - "success": False, - "output": "", - "error": str(e) - } - - async def _run_tmux_command(self, command: List[str]) -> Dict[str, any]: - """Run tmux command asynchronously. - - Args: - command: Tmux command as list - - Returns: - Dictionary with success status and error - """ - try: - process = await asyncio.create_subprocess_exec( - *command, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - - stdout, stderr = await process.communicate() - - if process.returncode == 0: - return {"success": True, "error": None} - else: - return { - "success": False, - "error": stderr.decode('utf-8', errors='ignore') - } - - except Exception as e: - return { - "success": False, - "error": str(e) - } - - async def _check_tmux_status(self) -> Dict[str, str]: - """Check tmux session status. - - Returns: - Dictionary with status message - """ - try: - tmux_check = await asyncio.create_subprocess_exec( - "tmux", "list-sessions", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - tmux_stdout, tmux_stderr = await tmux_check.communicate() - - tmux_sessions = tmux_stdout.decode().strip() - - if self.tmux_session in tmux_sessions: - return {"message": "Active"} - else: - return {"message": "Not found"} - - except Exception: - return {"message": "Error checking tmux"} - - async def _check_git_status(self) -> Dict[str, any]: - """Check git repository status. - - Returns: - Dictionary with git status information - """ - try: - git_check = await asyncio.create_subprocess_exec( - "git", "status", "--porcelain", - cwd=self.project_path, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - git_stdout, git_stderr = await git_check.communicate() - - git_status = git_stdout.decode().strip() - - if not git_status: - return {"message": "Clean", "modified_files": []} - else: - modified_files = [line.strip()[3:] for line in git_status.split('\n') if line.strip()] - return { - "message": "Modified files detected", - "modified_files": modified_files - } - - except Exception: - return {"message": "Error checking git", "modified_files": []} - - async def _get_current_branch(self) -> Optional[str]: - """Get current git branch. - - Returns: - Current branch name or None if error - """ - try: - branch_check = await asyncio.create_subprocess_exec( - "git", "branch", "--show-current", - cwd=self.project_path, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - branch_stdout, branch_stderr = await branch_check.communicate() - - return branch_stdout.decode().strip() - - except Exception: - return None - - async def _send_update_status(self, update: Update, branch: str, - restart_result: Dict[str, any], commit_log: str, - git_result: Dict[str, any]) -> None: - """Send update status message to user. - - Args: - update: Telegram update object - branch: Git branch name - restart_result: Result from bot restart - commit_log: Formatted commit log - git_result: Result from git operations - """ - # Pre-escape all strings yang akan dipakai di f-string untuk menghindari - # backslash di dalam expression f-string (tidak didukung di Python 3.6) - safe_branch = self._escape_markdown(branch) - safe_path = self._escape_markdown(str(self.project_path)) - safe_session = self._escape_markdown(self.tmux_session) - date_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S') - safe_date = self._escape_markdown(date_time) - - if restart_result["success"]: - status_message = ( - "✨ *Update berhasil\\!* ✨\n\n" - f"📂 Branch: `{safe_branch}`\n" - f"📁 Path: `{safe_path}`\n" - f"🔄 Bot direstart via tmux: `{safe_session}`\n" - f"⏰ Waktu: {safe_date}\n\n" - f"{commit_log}" - ) - else: - error_msg = self._escape_markdown(restart_result.get("error", "Unknown error")) - tmux_cmd1 = "tmux send-keys -t " + self.tmux_session + " C-c" - tmux_cmd2 = "tmux send-keys -t " + self.tmux_session + " 'python main.py' Enter" - safe_cmd1 = self._escape_markdown(tmux_cmd1) - safe_cmd2 = self._escape_markdown(tmux_cmd2) - - status_message = ( - "⚠️ *Update git berhasil, tapi restart gagal\\!* ⚠️\n\n" - f"📂 Branch: `{safe_branch}`\n" - f"📁 Path: `{safe_path}`\n" - f"❌ Tmux error: `{error_msg}`\n\n" - f"{commit_log}\n\n" - "_Silakan restart manual dengan:_\n" - f"`{safe_cmd1}`\n" - f"`{safe_cmd2}`" - ) - - await update.message.reply_text( - status_message, - parse_mode=ParseMode.MARKDOWN_V2 - ) - - async def _send_update_status_html(self, update: Update, branch: str, - restart_result: Dict[str, any], commit_log: str, - git_result: Dict[str, any]) -> None: - """Send update status message to user in HTML format.""" - if restart_result["success"]: - status_message = ( - "✨ Update berhasil! ✨\n\n" - f"📂 Branch: {html.escape(branch)}\n" - f"📁 Path: {html.escape(str(self.project_path))}\n" - f"🔄 Bot direstart via tmux: {html.escape(self.tmux_session)}\n" - f"⏰ Waktu: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" - f"{commit_log}" - ) - else: - error_msg = html.escape(restart_result.get("error", "Unknown error")) - status_message = ( - "⚠️ Update git berhasil, tapi restart gagal! ⚠️\n\n" - f"📂 Branch: {html.escape(branch)}\n" - f"📁 Path: {html.escape(str(self.project_path))}\n" - f"❌ Tmux error: {error_msg}\n\n" - f"{commit_log}\n\n" - f"Silakan restart manual dengan:\n" - f"tmux send-keys -t {html.escape(self.tmux_session)} C-c\n" - f"tmux send-keys -t {html.escape(self.tmux_session)} 'python main.py' Enter" - ) - - await update.message.reply_text( - status_message, - parse_mode=ParseMode.HTML - ) - - def _is_authorized_user(self, user_id: int) -> bool: - """Check if user is authorized to perform admin operations. - - Args: - user_id: Telegram user ID - - Returns: - True if user is authorized - """ - return user_id in self.authorized_users - - def _is_valid_branch_name(self, branch: str) -> bool: - """Validate git branch name. - - Args: - branch: Branch name to validate - - Returns: - True if branch name is valid - """ - if not branch or len(branch) > 50: - return False - - # Allow alphanumeric, hyphens, underscores, and slashes - allowed_chars = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_/") - return all(char in allowed_chars for char in branch) - - def _escape_markdown(self, text: str) -> str: - """Escape special characters for MarkdownV2. - - Args: - text: Text to escape - - Returns: - Escaped text safe for MarkdownV2 - """ - if not text: - return "" - - special_chars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'] - - for char in special_chars: - text = text.replace(char, f'\\{char}') - - return text - - def _get_project_info_html(self) -> Optional[str]: - """Get project information from common files in HTML format.""" - # Try to get project name from setup.py, pyproject.toml, or package.json - info_files = [ - ("setup.py", "name"), - ("pyproject.toml", "name"), - ("package.json", "name"), - ("README.md", "title") - ] - - for filename, _ in info_files: - file_path = self.project_path / filename - if file_path.exists(): - try: - content = file_path.read_text(encoding='utf-8') - if "alya" in content.lower() or "telegram" in content.lower(): - return f"📦 Project: {html.escape(filename)} detected" - except (OSError, UnicodeDecodeError): - continue - - return None - - async def _generate_commit_log_html(self) -> str: - """Generate formatted commit log for Telegram message in HTML.""" - try: - log_result = await self._run_git_command([ - "git", "log", f"--max-count={self.max_commit_display}", - "--pretty=format:%h|%an|%s|%ar" - ]) - - if not log_result["success"]: - return "❌ Gagal mengambil commit log" - - commits = log_result["output"].strip().split('\n') - - if not commits or commits == ['']: - return "📝 Tidak ada commit terbaru" - - commit_lines = ["🔄 Recent Commits:\n"] - - for commit in commits[:self.max_commit_display]: - if not commit.strip(): - continue - - parts = commit.split('|') - if len(parts) >= 4: - hash_short = parts[0] - author = parts[1] - message = parts[2] - time_ago = parts[3] - - # Truncate long commit messages - if len(message) > 50: - message = message[:47] + "..." - - # Escape for HTML - hash_escaped = html.escape(hash_short) - author_escaped = html.escape(author) - message_escaped = html.escape(message) - time_escaped = html.escape(time_ago) - - commit_lines.append( - f"{hash_escaped} {author_escaped}\n" - f"└─ {message_escaped}\n" - f" {time_escaped}\n" - ) - - return ''.join(commit_lines) - - except Exception as e: - logger.error(f"Failed to generate commit log: {e}") - return f"❌ Error generating commit log: {html.escape(str(e))}" - -def register_admin_handlers(application, tmux_session: Optional[str] = None) -> None: - """Register admin command handlers with the application. - - Args: - application: Telegram bot application instance - tmux_session: Optional tmux session name override - """ - admin_handler = DeploymentManager(tmux_session) - - # Register command handlers - application.add_handler(CommandHandler("update", admin_handler.update_handler)) - application.add_handler(CommandHandler("status", admin_handler.status_handler)) - application.add_handler(CommandHandler("restart", admin_handler.restart_handler)) - - logger.info(f"Admin handlers registered - Project: {admin_handler.project_path}") - logger.info(f"Tmux session: {admin_handler.tmux_session}") - logger.info(f"Authorized users: {len(admin_handler.authorized_users)}") \ No newline at end of file diff --git a/utils/voice_processor.py b/utils/voice_processor.py new file mode 100644 index 0000000..4cefff5 --- /dev/null +++ b/utils/voice_processor.py @@ -0,0 +1,75 @@ +""" +Lightweight Voice processor for Alya Bot (STT Only). +Handles only speech-to-text (STT). TTS is handled by the Alya-TTS microservice. +""" +import logging +import os +import asyncio +from pathlib import Path +from typing import Optional, Tuple + +logger = logging.getLogger(__name__) + +class VoiceProcessor: + """Lightweight voice processor for handling STT operations.""" + + def __init__(self): + """Initialize voice processor with only STT components.""" + self.temp_dir = Path("tmp") + self.temp_dir.mkdir(exist_ok=True) + self._initialize_stt() + logger.info(f"✅ Lightweight Voice processor initialized (STT: {self.recognizer is not None})") + + def _initialize_stt(self): + """Initialize speech recognition components.""" + try: + import speech_recognition as sr + self.recognizer = sr.Recognizer() + except ImportError: + logger.error("❌ speech_recognition not installed") + self.recognizer = None + + async def transcribe_audio(self, audio_path: str, lang: str = None) -> Optional[Tuple[str, str]]: + """Transcribe audio to text using Google Speech Recognition.""" + if lang is None: + from config.settings import DEFAULT_LANGUAGE + lang = DEFAULT_LANGUAGE + + if not self.recognizer: + return None + + try: + # Convert OGG to WAV if needed (requires pydub + ffmpeg) + wav_path = audio_path + if audio_path.endswith('.ogg'): + wav_path = str(self.temp_dir / f"stt_{os.getpid()}_{os.urandom(4).hex()}.wav") + from pydub import AudioSegment + audio = AudioSegment.from_file(audio_path, format="ogg") + audio.export(wav_path, format="wav") + + import speech_recognition as sr + with sr.AudioFile(wav_path) as source: + audio_data = self.recognizer.record(source) + + # Map language codes for Google SR + lang_map = {"en": "en-US", "id": "id-ID", "ru": "ru-RU", "jp": "ja-JP"} + target_lang = lang_map.get(lang, "id-ID") + + # Simple wrapper to run blocking recognize_google in thread + text = await asyncio.to_thread(self.recognizer.recognize_google, audio_data, language=target_lang) + return text, lang + + except Exception as e: + logger.error(f"❌ Transcription error: {e}") + return None + finally: + if 'wav_path' in locals() and wav_path != audio_path: + self._safe_remove(wav_path) + + def _safe_remove(self, path: str): + """Safely remove a file.""" + try: + if os.path.exists(path): + os.unlink(path) + except Exception as e: + logger.warning(f"Failed to delete {path}: {e}")