Files
VolcanicAgenticCore/tests/Volcanic.AgenticCore.Telegram.Tests/VoiceToolChatTests.cs
EugeneTes 87240238cc feat(telegram): voice tool sends spoken replies as Telegram voice notes
OpenAI gpt-4o-mini-tts (female voice nova by default; TTS_MODEL/TTS_VOICE) renders OGG/Opus,
sent with sendVoice in order with rendered output. Up to 4500 characters per call, split
into 1500-character parts at paragraph/sentence/word boundaries; all parts are synthesized
before any is sent, and API errors come back to the agent as text with the key hidden.
On whenever OPENAI_API_KEY is set.
2026-10-09 10:09:46 +00:00

123 lines
5.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Telegram.Bot.Exceptions;
using Telegram.Bot.Requests;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Speech;
using Volcanic.AgenticCore.Telegram.Tests.Support;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Telegram.Tests;
public sealed class VoiceToolChatTests
{
private static async Task<ToolResultMessage> ResultAsync(ChatHost host) =>
(await host.ChatAsync()).Session.Messages.OfType<ToolResultMessage>().Single();
[Fact]
public async Task Voice_text_is_spoken_and_sent_as_a_voice_note_before_the_answer()
{
var tts = new FakeTextToSpeech();
await using var host = new ChatHost(tts: tts);
host.Faux.Enqueue(
FauxTurn.Tool("voice", new { text = "Привет! Встреча в три.", voice = "coral", instructions = "warm" }),
FauxTurn.Say("Sent."));
await host.SendAsync("скажи голосом");
await host.SettleAsync();
Assert.Equal(("Привет! Встреча в три.", "coral", "warm"), Assert.Single(tts.Calls));
var upload = Assert.Single(host.Bot.Uploads);
Assert.Equal(("sendVoice", "voice-1.ogg"), (upload.Method, upload.FileName));
Assert.Equal(new byte[] { 0x4F, 0x67, 0x67, 0x53, 1 }, upload.Bytes);
var requests = host.Bot.Requests.ToList();
Assert.InRange(requests.FindIndex(r => r is SendVoiceRequest), 0,
requests.FindIndex(r => r is SendMessageRequest m && m.Text.Contains("Sent.", StringComparison.Ordinal)) - 1);
var result = await ResultAsync(host);
Assert.False(result.IsError);
Assert.Equal("Sent 1 voice message.", result.Text);
}
[Fact]
public async Task Long_text_goes_out_as_several_voice_notes_in_order()
{
var tts = new FakeTextToSpeech();
await using var host = new ChatHost(tts: tts);
var sentence = new string('а', 99) + ". ";
var text = string.Concat(Enumerable.Repeat(sentence, 20)).Trim(); // about 2000 characters
host.Faux.Enqueue(FauxTurn.Tool("voice", new { text }), FauxTurn.Say("Done."));
await host.SendAsync("long voice");
await host.SettleAsync();
Assert.Equal(2, tts.Calls.Count);
Assert.All(tts.Calls, c => Assert.True(c.Text.Length <= VoiceExtension.MaxPartLength));
Assert.Equal(["voice-1.ogg", "voice-2.ogg"], host.Bot.Uploads.Select(u => u.FileName));
Assert.Equal("Sent 2 voice messages.", (await ResultAsync(host)).Text);
}
[Fact]
public async Task Too_long_text_is_refused_without_calling_the_api()
{
var tts = new FakeTextToSpeech();
await using var host = new ChatHost(tts: tts);
host.Faux.Enqueue(FauxTurn.Tool("voice", new { text = new string('x', VoiceExtension.MaxTextLength + 1) }), FauxTurn.Say("Too long."));
await host.SendAsync("very long voice");
await host.SettleAsync();
Assert.Empty(tts.Calls);
Assert.Empty(host.Bot.Uploads);
var result = await ResultAsync(host);
Assert.True(result.IsError);
Assert.Contains("at most 4500", result.Text, StringComparison.Ordinal);
}
[Fact]
public async Task Api_errors_come_back_as_text_and_nothing_is_sent()
{
var tts = new FakeTextToSpeech { Failure = new TextToSpeechException("OpenAI speech synthesis failed: HTTP 401 bad key") };
await using var host = new ChatHost(tts: tts);
host.Faux.Enqueue(FauxTurn.Tool("voice", new { text = "hello" }), FauxTurn.Say("It failed."));
await host.SendAsync("voice");
await host.SettleAsync();
Assert.Empty(host.Bot.Uploads);
var result = await ResultAsync(host);
Assert.True(result.IsError);
Assert.Equal("OpenAI speech synthesis failed: HTTP 401 bad key Nothing was sent.", result.Text);
}
[Fact]
public async Task A_refused_voice_note_is_reported()
{
await using var host = new ChatHost(tts: new FakeTextToSpeech());
host.Bot.OnSend = r => { if (r is SendVoiceRequest) throw new ApiRequestException("Forbidden: VOICE_MESSAGES_FORBIDDEN", 400); };
host.Faux.Enqueue(FauxTurn.Tool("voice", new { text = "hello" }), FauxTurn.Say("It failed."));
await host.SendAsync("voice");
await host.SettleAsync();
var result = await ResultAsync(host);
Assert.True(result.IsError);
Assert.Contains("VOICE_MESSAGES_FORBIDDEN", result.Text, StringComparison.Ordinal);
}
[Fact]
public async Task Without_text_to_speech_there_is_no_voice_tool()
{
await using var host = new ChatHost();
var chat = await host.ChatAsync();
Assert.DoesNotContain(chat.Session.Tools, t => t.Name == "voice");
}
[Theory]
[InlineData("One. Two. Three.", 100, new[] { "One. Two. Three." })]
[InlineData("Aaaa bbbb. Cccc dddd.", 12, new[] { "Aaaa bbbb.", "Cccc dddd." })]
[InlineData("aaaa bbbb cccc", 9, new[] { "aaaa bbbb", "cccc" })]
[InlineData("abcdefghij", 4, new[] { "abcd", "efgh", "ij" })]
[InlineData("First para\n\nSecond para", 15, new[] { "First para", "Second para" })]
public void Splits_at_paragraphs_sentences_spaces_then_hard(string text, int max, string[] expected) =>
Assert.Equal(expected, VoiceExtension.SplitForSpeech(text, max));
}