#!/usr/bin/env python # -*- coding: utf-8 -*- """ TF5 Mixer Telegram Bot - Controllo del mixer Yamaha TF5 tramite Telegram usando Google Gemini con function calling e long polling. """ import json import time import sys import os from typing import Dict, List, Optional from urllib.request import urlopen, Request from urllib.error import URLError, HTTPError from urllib.parse import urlencode from google import genai from google.genai import types from dotenv import load_dotenv from mixer_controller import TF5MixerController load_dotenv() from config import * class TelegramBot: """Bot Telegram base con long polling.""" def __init__(self, token: str): self.token = token self.base_url = f"https://api.telegram.org/bot{token}" self.offset = 0 def _make_request(self, method: str, params: Optional[Dict] = None) -> Dict: """Esegue una richiesta all'API di Telegram.""" url = f"{self.base_url}/{method}" try: if params: data = json.dumps(params).encode('utf-8') headers = {'Content-Type': 'application/json'} req = Request(url, data=data, headers=headers) else: req = Request(url) with urlopen(req, timeout=30) as response: return json.loads(response.read().decode('utf-8')) except HTTPError as e: error_body = e.read().decode('utf-8') print(f"❌ HTTP Error {e.code}: {error_body}") return {"ok": False, "description": error_body} except URLError as e: print(f"❌ URL Error: {e.reason}") return {"ok": False, "description": str(e.reason)} except Exception as e: print(f"❌ Error: {e}") return {"ok": False, "description": str(e)} def get_updates(self, timeout: int = 30) -> List[Dict]: """Ottiene gli aggiornamenti usando long polling.""" params = { "offset": self.offset, "timeout": timeout, "allowed_updates": ["message"] } result = self._make_request("getUpdates", params) if result.get("ok"): updates = result.get("result", []) if updates: self.offset = updates[-1]["update_id"] + 1 return updates return [] def send_message(self, chat_id: int, text: str, parse_mode: Optional[str] = None) -> Dict: """Invia un messaggio a una chat.""" params = { "chat_id": chat_id, "text": text } if parse_mode: params["parse_mode"] = parse_mode return self._make_request("sendMessage", params) def send_chat_action(self, chat_id: int, action: str = "typing") -> Dict: """Invia un'azione (es. "typing").""" params = { "chat_id": chat_id, "action": action } return self._make_request("sendChatAction", params) class TF5TelegramBot: """Bot Telegram per controllare il mixer TF5.""" def __init__(self, telegram_token: str, mixer_host: str = DEFAULT_HOST, mixer_port: int = DEFAULT_PORT): self.bot = TelegramBot(telegram_token) self.controller = TF5MixerController(mixer_host, mixer_port) # Configura Gemini api_key = os.getenv("GEMINI_API_KEY") if not api_key: raise ValueError("GEMINI_API_KEY non trovata") self.client = genai.Client(api_key=api_key) # Configurazione Gemini con function calling self.config = types.GenerateContentConfig( tools=[ self.controller.recall_scene, self.controller.set_channel_level, self.controller.set_channel_on_off, self.controller.set_channel_pan, self.controller.set_mix_level, self.controller.set_mix_on_off, self.controller.mute_multiple_channels, self.controller.unmute_multiple_channels, self.controller.get_channel_info, self.controller.get_mix_info, self.controller.search_channels_by_name, self.controller.search_mixes_by_name, self.controller.get_all_channels_summary, self.controller.get_all_mixes_summary, self.controller.refresh_cache, self.controller.set_channel_to_mix_level, self.controller.set_channel_to_mix_on_off, self.controller.get_channel_to_mix_info, ], temperature=0, ) # Storia delle conversazioni per ogni utente self.conversations: Dict[int, List[Dict]] = {} # System instruction self.system_instruction = """Sei un assistente per il controllo del mixer audio Yamaha TF5. Parli in modo semplice e diretto, come un tecnico del suono esperto che aiuta i musicisti sul palco. Il mixer ha: - 40 canali (microfoni, strumenti, ecc.) - 20 mix/aux (monitor, effetti, ecc.) - Ogni canale ha volume (da silenzio a +10 dB), acceso/spento, e bilanciamento sinistra/destra - Scene memorizzate nei banchi A e B (da 0 a 99) IMPORTANTE - Sistema di Cache: - Le info sui canali e mix sono salvate per 60 minuti per non sovraccaricare il mixer - Usa search_channels_by_name e search_mixes_by_name per cercare velocemente - Usa get_all_channels_summary e get_all_mixes_summary per vedere tutto - Quando modifichi un canale/mix, la cache viene aggiornata automaticamente - Quando si carica una scena, i dati vengono invalidati e aggiornati alla prossima richiesta - Puoi fare refresh_cache solo se l'utente lo chiede esplicitamente Come interpretare le richieste: VOLUME/LIVELLO: - "alza/abbassa/aumenta/diminuisci" → cambia il volume - "più/meno forte/volume" → cambia il volume - "al massimo" → +10 dB - "un po' più alto" → +3 dB circa - "metti a zero" o "unity" → 0 dB - "abbassa di poco" → -3 dB - "metti basso" → -20 dB - "silenzio/muto" → spegni il canale ON/OFF: - "accendi/attiva/apri" → canale/mix ON - "spegni/muta/chiudi/stacca" → canale/mix OFF - "muto" può significare sia spegnere che abbassare molto BILANCIAMENTO (PAN): - "a sinistra/left" → pan -63 - "a destra/right" → pan +63 - "al centro" → pan 0 - "un po' a sinistra" → pan -30 circa IDENTIFICAZIONE CANALI E MIX: - Accetta sia numeri ("canale 5", "mix 3") che nomi ("il microfono del cantante", "monitor palco") - Se non trovi un canale/mix per nome, cerca usando search_channels_by_name o search_mixes_by_name - "il mio mic/microfono" → cerca tra i canali chi è sul palco - "le chitarre/i vox/le tastiere" → cerca per strumento - "tutti i mic/tutte le chitarre" → cerca e gestisci multipli - "il monitor" / "l'aux 2" → cerca tra i mix SEND AI MIX/AUX: - "alza il gelato nell'aux 2" → usa set_channel_to_mix_level - "manda più chitarra nel monitor" → aumenta il send - "togli la voce dal mix 3" → spegni il send o abbassa molto - i mix 9/10, 11/12, 13/14, 15/16, 17/18, 19/20 sono stereo, fai le operazioni su entrambi SCENE: - "carica/richiama/vai alla scena X" → recall_scene - Accetta "A5", "scena A 5", "la cinque del banco A", ecc. GRUPPI DI CANALI: - "i canali dal 3 al 7" → canali 3,4,5,6,7 - "spegni tutto tranne..." → muta tutti gli altri - "solo i microfoni" → attiva solo quelli, spegni il resto CASI PARTICOLARI: - Se la richiesta è ambigua, chiedi chiarimenti in modo colloquiale - Se serve cercare un canale/mix, usa prima la cache (search_channels_by_name / search_mixes_by_name) - Conferma sempre cosa hai fatto con un messaggio breve e chiaro - Usa emoji occasionalmente per rendere le risposte più amichevoli (✅ ❌ 🎤 🎸 🔊) - Se qualcosa non funziona, spiega il problema in modo semplice Rispondi sempre in modo: - Diretto e colloquiale - Senza troppi tecnicismi - Confermando chiaramente l'azione eseguita - Suggerendo alternative se qualcosa non è possibile Ricorda: chi ti parla è spesso sul palco, con le mani occupate da uno strumento. Devi essere veloce, chiaro e capire anche richieste approssimative. """ def get_conversation_history(self, user_id: int) -> List[Dict]: """Ottiene la storia della conversazione per un utente.""" if user_id not in self.conversations: self.conversations[user_id] = [] return self.conversations[user_id] def add_to_history(self, user_id: int, role: str, content: str): """Aggiunge un messaggio alla storia.""" history = self.get_conversation_history(user_id) history.append({"role": role, "content": content}) # Mantieni solo gli ultimi 20 messaggi per evitare context overflow if len(history) > 20: self.conversations[user_id] = history[-20:] def clear_history(self, user_id: int): """Cancella la storia di un utente.""" self.conversations[user_id] = [] def process_message(self, user_id: int, username: str, text: str) -> str: """Elabora un messaggio dell'utente.""" try: # Comandi speciali if text.lower() in ['/start', '/help']: return self._get_help_message() if text.lower() == '/reset': self.clear_history(user_id) return "🔄 Storia della conversazione cancellata. Ricominciamo da capo!" if text.lower() == '/status': return self._get_status_message() # Aggiungi il messaggio alla storia self.add_to_history(user_id, "user", text) # Prepara il prompt con la storia history = self.get_conversation_history(user_id) conversation_context = "\n".join([ f"{'Utente' if msg['role'] == 'user' else 'Assistente'}: {msg['content']}" for msg in history[:-1] # Escludi l'ultimo (già incluso sotto) ]) full_prompt = f"{self.system_instruction}\n\n" if conversation_context: full_prompt += f"Conversazione precedente:\n{conversation_context}\n\n" full_prompt += f"Utente: {text}" # Genera risposta con Gemini response = self.client.models.generate_content( model="gemini-2.5-flash", contents=full_prompt, config=self.config, ) assistant_response = response.text # Aggiungi la risposta alla storia self.add_to_history(user_id, "assistant", assistant_response) return assistant_response except Exception as e: return f"❌ Errore nell'elaborazione: {e}" def _get_help_message(self) -> str: """Restituisce il messaggio di aiuto.""" return """🎛️ **TF5 Mixer Bot** Controlla il mixer Yamaha TF5 con comandi in linguaggio naturale! **Esempi di comandi:** • "Alza il canale 5 a -10 dB" • "Spegni i canali dal 1 al 5" • "Richiama la scena A10" • "Muta i canali 2, 4 e 6" • "Alza il mix 3 di 5 dB" • "Mostrami lo stato del canale 12" • "Cerca i monitor" **Comandi speciali:** /help - Mostra questo messaggio /reset - Cancella la storia della conversazione /status - Mostra stato del sistema Scrivi semplicemente cosa vuoi fare e ci penso io! 🎵""" def _get_status_message(self) -> str: """Restituisce lo stato del sistema.""" cache_age = time.time() - self.controller._cache.get("timestamp", 0) cache_valid = self.controller._is_cache_valid() status = "📊 **Status del Sistema**\n\n" status += f"🎛️ Mixer: Connesso a {self.controller.host}:{self.controller.port}\n" if cache_valid: channels_count = len(self.controller._cache.get("channels", {})) mixes_count = len(self.controller._cache.get("mixes", {})) status += f"💾 Cache: Valida (età: {int(cache_age)}s)\n" status += f"📊 Dati: {channels_count} canali, {mixes_count} mix\n" else: status += "💾 Cache: Non disponibile\n" users_count = len(self.conversations) total_messages = sum(len(hist) for hist in self.conversations.values()) status += f"👥 Utenti attivi: {users_count}\n" status += f"💬 Messaggi totali: {total_messages}" return status def handle_update(self, update: Dict): """Gestisce un aggiornamento di Telegram.""" if "message" not in update: return message = update["message"] # Ignora messaggi non testuali if "text" not in message: return chat_id = message["chat"]["id"] user_id = message["from"]["id"] username = message["from"].get("username", "Unknown") text = message["text"] print(f"📨 Messaggio da @{username} (ID: {user_id}): {text}") # Mostra "typing..." self.bot.send_chat_action(chat_id, "typing") # Elabora il messaggio response = self.process_message(user_id, username, text) # Invia la risposta self.bot.send_message(chat_id, response) print(f"✅ Risposta inviata a @{username}") def run(self): """Avvia il bot con long polling.""" print("=" * 70) print("🤖 TF5 Mixer Telegram Bot") print("=" * 70) # Verifica connessione me = self.bot._make_request("getMe") if me.get("ok"): bot_info = me["result"] print(f"\n✅ Bot avviato: @{bot_info['username']}") print(f" Nome: {bot_info['first_name']}") print(f" ID: {bot_info['id']}") else: print("❌ Errore nella verifica del bot") return # Mostra stato cache cache_age = time.time() - self.controller._cache.get("timestamp", 0) if self.controller._is_cache_valid(): channels_count = len(self.controller._cache.get("channels", {})) mixes_count = len(self.controller._cache.get("mixes", {})) print(f"\n💾 Cache disponibile (età: {int(cache_age)}s)") print(f" 📊 {channels_count} canali, {mixes_count} mix") else: print("\n💾 Cache non disponibile, verrà creata al primo utilizzo") print("\n🔄 In attesa di messaggi... (Ctrl+C per terminare)\n") try: while True: try: updates = self.bot.get_updates() for update in updates: try: self.handle_update(update) except Exception as e: print(f"❌ Errore nell'elaborazione update: {e}") time.sleep(0.1) # Piccola pausa per non sovraccaricare except KeyboardInterrupt: raise except Exception as e: print(f"❌ Errore nel polling: {e}") time.sleep(5) # Attendi prima di riprovare except KeyboardInterrupt: print("\n\n👋 Bot terminato") def close(self): """Chiude le connessioni.""" self.controller.close() def main(): """Funzione principale.""" import argparse parser = argparse.ArgumentParser( description='TF5 Mixer Telegram Bot' ) parser.add_argument('--mixer-host', default=DEFAULT_HOST, help=f'IP del mixer (default: {DEFAULT_HOST})') parser.add_argument('--mixer-port', type=int, default=DEFAULT_PORT, help=f'Porta mixer (default: {DEFAULT_PORT})') args = parser.parse_args() # Verifica variabili d'ambiente telegram_token = os.getenv("TELEGRAM_BOT_TOKEN") if not telegram_token: print("❌ Errore: TELEGRAM_BOT_TOKEN non trovata") print("\nPer impostare il token:") print(" export TELEGRAM_BOT_TOKEN='il-tuo-token'") print("\nOttieni un token da @BotFather su Telegram") sys.exit(1) gemini_key = os.getenv("GEMINI_API_KEY") if not gemini_key: print("❌ Errore: GEMINI_API_KEY non trovata") print("\nPer impostare la chiave API:") print(" export GEMINI_API_KEY='la-tua-chiave-api'") print("\nOttieni una chiave su: https://aistudio.google.com/apikey") sys.exit(1) try: bot = TF5TelegramBot( telegram_token=telegram_token, mixer_host=args.mixer_host, mixer_port=args.mixer_port ) bot.run() except Exception as e: print(f"❌ Errore fatale: {e}") sys.exit(1) finally: bot.close() if __name__ == '__main__': main()