Este es el episodio 6 de 6 del curso, el que cierra el ciclo: el agente que arrancamos en el episodio 2, le dimos voz en el 3, cerebro en el 4 y conocimiento del negocio en el 5, ahora recibe mensajes reales de Telegram y responde con notas de voz reales.
Crear el bot con BotFather
Todo bot de Telegram nace hablando con @BotFather, el bot oficial de Telegram para crear bots. Le escribes /newbot y respondes dos preguntas:
BotFather: Alright, a new bot. How are you going to call it?Tú: Clinica Dental Sonrisa Bot
BotFather: Good. Now let's choose a username for your bot. It must end in 'bot'.Tú: ClinicaSonrisaBot
BotFather: Done! Congratulations on your new bot. Use this token to access the HTTP API: 123456789:AAF-xxxxxxxxxxxxxxxxxxxxxxxxxxxEse token es la API key del bot. Nunca se comparte ni se sube a un repositorio. Va directo a User Secrets, junto a las claves de Azure y Google que ya configuramos en episodios anteriores:
{ "Azure": { "SpeechKey": "...", "SpeechRegion": "eastus" }, "Google": { "ApiKey": "AIza..." }, "Telegram": { "BotToken": "123456789:AAF-xxx..." }}VoiceService: un método que devuelve bytes en vez de reproducir audio
Hasta el episodio anterior, el agente vivía en una consola y VoiceService reproducía el audio directo en el speaker de la máquina. Para Telegram eso no sirve: hace falta el audio como bytes, en formato Ogg/Opus, que es el único formato que Telegram acepta para notas de voz.
using Microsoft.CognitiveServices.Speech;using Microsoft.CognitiveServices.Speech.Audio;
namespace ClinicaDentalSonrisa.TelegramBot.Voice;
internal class VoiceService{ private readonly SpeechConfig _speechConfig;
public VoiceService(string key, string region, string voiceName) { _speechConfig = SpeechConfig.FromSubscription(key, region); _speechConfig.SpeechSynthesisVoiceName = voiceName; _speechConfig.SetSpeechSynthesisOutputFormat( SpeechSynthesisOutputFormat.Ogg48Khz16BitMonoOpus ); }
public async Task<byte[]> GenerateAudioAsync(string text) { using var synthesizer = new SpeechSynthesizer( _speechConfig, AudioConfig.FromDefaultSpeakerOutput() );
var result = await synthesizer.SpeakTextAsync(text); if (result.Reason == ResultReason.SynthesizingAudioCompleted) return result.AudioData;
throw new Exception($"Error generando audio: {result.Reason}"); }}SetSpeechSynthesisOutputFormat(Ogg48Khz16BitMonoOpus) es la línea que hace que el audio generado sea compatible con SendVoice de Telegram. GenerateAudioAsync devuelve result.AudioData en vez de solo reproducir: esos bytes son los que el BotHandler va a mandar como nota de voz.
BotHandler: la clase que conecta todo
Un archivo nuevo, Telegram/BotHandler.cs, que recibe el agente y el VoiceService ya construidos, y procesa cada mensaje entrante:
using ClinicaDentalSonrisa.TelegramBot.Voice;using Microsoft.Agents.AI;using Telegram.Bot;using Telegram.Bot.Types;using Telegram.Bot.Types.Enums;
namespace ClinicaDentalSonrisa.TelegramBot.Telegram;
internal class BotHandler{ private readonly AIAgent _agent; private readonly VoiceService _voiceService;
public BotHandler(AIAgent aIAgent, VoiceService voiceService) { _agent = aIAgent; _voiceService = voiceService; }
public async Task HandleUpdateAsync( ITelegramBotClient telegramBotClient, Update update, CancellationToken cancellationToken) { // solo procesaremos mensajes de texto if (update.Type != UpdateType.Message) return; if (update.Message!.Text is not { } userText) return;
var chatId = update.Message.Chat.Id;
try { await telegramBotClient.SendChatAction( chatId, ChatAction.RecordVoice, cancellationToken: cancellationToken);
// el agente piensa con gemini var response = await _agent.RunAsync(userText); var responseText = response.Text;
// azure tts genera el audio en memoria var audioBytes = await _voiceService.GenerateAudioAsync(responseText);
// enviamos la nota de voz a telegram using var audioStream = new MemoryStream(audioBytes);
await telegramBotClient.SendVoice( chatId: chatId, voice: InputFile.FromStream(audioStream, "response.ogg"), cancellationToken: cancellationToken ); } catch (Exception exception) { Console.WriteLine($"Error: {exception.Message}");
await telegramBotClient.SendMessage( chatId: chatId, text: "Lo siento, en este momento no puedo procesar tu mensaje. " + "Intenta de nuevo más tarde.", cancellationToken: cancellationToken ); } }
public Task HandleErrorAsync( ITelegramBotClient telegramBotClient, Exception exception, CancellationToken cancellationToken) { Console.WriteLine($"Error en bothandler: {exception}"); return Task.CompletedTask; }}Tres detalles importantes. SendChatAction con ChatAction.RecordVoice hace que Telegram muestre “grabando audio…” mientras el bot procesa: un detalle pequeño que hace que el bot se sienta real. update.Message!.Text is not { } userText es pattern matching de C# que en una sola línea verifica que el texto no sea null y lo asigna a userText. Y el try-catch alrededor de todo el flujo asegura que, si Gemini o Azure fallan, el usuario recibe un mensaje de texto en vez de que el bot se quede en silencio.
Program.cs: la composición final
Todo lo construido en los cinco episodios anteriores se junta aquí:
using ClinicaDentalSonrisa.TelegramBot.Telegram;using ClinicaDentalSonrisa.TelegramBot.Voice;using Google.GenAI;using Microsoft.Agents.AI;using Microsoft.Extensions.AI;using Microsoft.Extensions.Configuration;using Telegram.Bot;
var config = new ConfigurationBuilder() .AddUserSecrets<Program>() .Build();
var voiceService = new VoiceService( config["Azure:SpeechKey"]!, config["Azure:SpeechRegion"]!, config["Azure:VoiceName"]!);
string businessInfo = await File.ReadAllTextAsync("business-info.md");
IChatClient chatClient = new Client(apiKey: config["Google:ApiKey"]!) .AsIChatClient(config["Google:LanguageModel"]);
AIAgent agent = new ChatClientAgent( chatClient, instructions: $""" Eres un asistente virtual de la siguiente clínica dental. Usa ÚNICAMENTE la información del negocio para responder. Si no sabes algo, di que no tienes esa información. Responde siempre en español, de forma amable y concisa. La moneda, símbolo y país están definidos en la sección "Configuración" del documento. Úsalos siempre al mencionar precios.
=== INFORMACIÓN DEL NEGOCIO === {businessInfo} """, name: "ClinicaDentalSonrisaBot");
var botClient = new TelegramBotClient(config["Telegram:BotToken"]!);var handler = new BotHandler(agent, voiceService);
var cancellationToken = new CancellationTokenSource();
botClient.StartReceiving( updateHandler: handler.HandleUpdateAsync, errorHandler: handler.HandleErrorAsync, receiverOptions: new Telegram.Bot.Polling.ReceiverOptions { AllowedUpdates = [] }, cancellationToken: cancellationToken.Token);
var me = await botClient.GetMe();Console.WriteLine($"Bot @{me.Username} corriendo...");Console.WriteLine("Presiona Enter para detener.");Console.ReadLine();
cancellationToken.Cancel();GetMe() confirma que el token es válido y que el bot quedó conectado antes de imprimir el nombre de usuario. StartReceiving arranca el polling en segundo plano: Telegram no necesita saber la IP ni el puerto de tu máquina, es tu proceso el que pregunta por mensajes nuevos.
La demo: mensajes reales, respuestas reales
✅ Bot @ClinicaSonrisaBot corriendo...Presiona Enter para detener.Desde el teléfono, en Telegram real:
Tú: ¿Cuánto cuesta el blanqueamiento?Bot: (nota de voz) "El blanqueamiento dental tiene un costo de $120."
Tú: ¿Atienden los sábados?Bot: (nota de voz) "Lo sentimos, no atendemos los sábados ni domingos. Nuestro horario es de lunes a viernes de 8AM a 6PM."
Tú: ¿Cuánto cuesta un Ferrari?Bot: (nota de voz) "Lo siento, no tengo esa información disponible. Solo puedo ayudarte con consultas de Clínica Dental Sonrisa."El mismo comportamiento del episodio 5 (precios reales, rechazo de preguntas fuera de contexto) ahora corre sobre Telegram real, con notas de voz reales, sin nada corriendo en tu teléfono.
Qué queda
El bot funciona mientras tu máquina esté encendida y el proceso siga corriendo: en cuanto cierras la consola, el polling se detiene y el bot deja de responder. Ponerlo a correr 24 horas al día en la nube es un problema distinto (de infraestructura, no de código) que queda fuera de estos seis episodios.
Con esto se cierra la serie: un proyecto en .NET 10, con voz humana en español, un agente que conoce un negocio real a través de un Markdown, y un canal de Telegram para llegar a cualquiera con la app instalada. El código completo, con los seis episodios integrados, está en el repositorio del proyecto.