274 lines
12 KiB
C#
274 lines
12 KiB
C#
using Microsoft.Extensions.Time.Testing;
|
|
using Telegram.Bot.Requests;
|
|
using Volcanic.AgenticCore.Telegram.Speech;
|
|
using Volcanic.AgenticCore.Telegram.Tests.Support;
|
|
using Volcanic.AgenticCore.Testing;
|
|
|
|
namespace Volcanic.AgenticCore.Telegram.Tests;
|
|
|
|
public sealed class VoiceChatTests
|
|
{
|
|
private const string NotConfigured = "Voice messages need speech-to-text: set OPENAI_API_KEY (or Telegram:Speech:ApiKey) on the bot host.";
|
|
private static string Prompt(ChatHost host) => host.Faux.Requests.Single().Messages.Last().Text;
|
|
|
|
[Fact]
|
|
public async Task A_voice_note_is_echoed_before_the_answer_and_prompted()
|
|
{
|
|
var stt = new FakeSpeechToText("remind me to call Ada");
|
|
await using var host = new ChatHost(speech: stt);
|
|
host.Bot.Files["voice"] = [1, 2, 3];
|
|
host.Faux.Enqueue(FauxTurn.Say("Will do."));
|
|
|
|
await host.RouteAsync(Updates.VoiceMessage());
|
|
await host.SettleAsync();
|
|
|
|
var texts = host.Texts.ToList();
|
|
Assert.InRange(texts.IndexOf("🎤 remind me to call Ada"), 0, texts.IndexOf("Will do.") - 1);
|
|
Assert.EndsWith("\n[Voice message] remind me to call Ada", Prompt(host), StringComparison.Ordinal);
|
|
var call = Assert.Single(stt.Calls);
|
|
Assert.Equal(("voice.ogg", "audio/ogg"), (call.FileName, call.MimeType));
|
|
Assert.Equal(new byte[] { 1, 2, 3 }, call.Audio);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_long_transcript_echo_is_cut_to_1024_characters()
|
|
{
|
|
var stt = new FakeSpeechToText(new string('a', 5000));
|
|
await using var host = new ChatHost(speech: stt);
|
|
host.Bot.Files["voice"] = [1];
|
|
host.Faux.Enqueue(FauxTurn.Say("ok"));
|
|
|
|
await host.RouteAsync(Updates.VoiceMessage());
|
|
await host.SettleAsync();
|
|
|
|
var echo = host.Bot.Sent.Single(s => s.Text.StartsWith("🎤", StringComparison.Ordinal)).Text;
|
|
Assert.Equal(1024, echo.Length);
|
|
Assert.EndsWith("…", echo, StringComparison.Ordinal);
|
|
Assert.Contains(new string('a', 5000), Prompt(host), StringComparison.Ordinal); // the model gets all of it
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Without_speech_to_text_a_voice_note_gets_the_setup_notice_and_no_run()
|
|
{
|
|
await using var host = new ChatHost();
|
|
host.Bot.Files["voice"] = [1];
|
|
await host.RouteAsync(Updates.VoiceMessage());
|
|
await host.SettleAsync();
|
|
|
|
Assert.Contains(NotConfigured, host.Texts);
|
|
Assert.Empty(host.Faux.Requests);
|
|
Assert.Empty(host.Bot.Requests.OfType<GetFileRequest>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Without_speech_to_text_a_captioned_voice_note_still_prompts_the_caption()
|
|
{
|
|
await using var host = new ChatHost();
|
|
host.Faux.Enqueue(FauxTurn.Say("ok"));
|
|
await host.RouteAsync(Updates.VoiceMessage(caption: "listen to this"));
|
|
await host.SettleAsync();
|
|
|
|
Assert.Contains(NotConfigured, host.Texts);
|
|
Assert.EndsWith("\nlisten to this", Prompt(host), StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_transcription_failure_gets_a_notice_and_no_run()
|
|
{
|
|
var stt = new FakeSpeechToText { Failure = new SpeechToTextException("OpenAI transcription failed: HTTP 500 oops") };
|
|
await using var host = new ChatHost(speech: stt);
|
|
host.Bot.Files["voice"] = [1];
|
|
await host.RouteAsync(Updates.VoiceMessage());
|
|
await host.SettleAsync();
|
|
|
|
Assert.Contains("Could not transcribe the voice message: OpenAI transcription failed: HTTP 500 oops", host.Texts);
|
|
Assert.Empty(host.Faux.Requests);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_transcription_timeout_gets_a_notice_and_no_run()
|
|
{
|
|
// HttpClient.Timeout surfaces as a TaskCanceledException although nobody cancelled the message.
|
|
var stt = new FakeSpeechToText { Failure = new TaskCanceledException("timeout", new TimeoutException()) };
|
|
await using var host = new ChatHost(speech: stt);
|
|
host.Bot.Files["voice"] = [1];
|
|
await host.RouteAsync(Updates.VoiceMessage());
|
|
await host.SettleAsync();
|
|
|
|
Assert.Contains("Could not transcribe the voice message: timeout", host.Texts);
|
|
Assert.Empty(host.Faux.Requests);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_audio_transcription_timeout_still_prompts_the_saved_file()
|
|
{
|
|
var time = new FakeTimeProvider(new DateTimeOffset(2026, 9, 26, 10, 0, 0, TimeSpan.Zero));
|
|
var stt = new FakeSpeechToText { Failure = new TaskCanceledException("timeout", new TimeoutException()) };
|
|
await using var host = new ChatHost(time: time, speech: stt);
|
|
host.Bot.Files["audio"] = [4, 5, 6];
|
|
host.Faux.Enqueue(FauxTurn.Say("ok"));
|
|
|
|
await host.RouteAsync(Updates.AudioMessage("talk.mp3", "audio/mpeg", 3));
|
|
await MessageContextChatTests.FlushAsync(host, time, 1);
|
|
await host.SettleAsync();
|
|
|
|
Assert.Contains("Could not transcribe the voice message: timeout", host.Texts);
|
|
Assert.EndsWith("[File received: inbox/2026-09-26-talk.mp3 (audio/mpeg, 3 bytes)]", Prompt(host), StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_empty_transcript_gets_a_notice_and_no_run()
|
|
{
|
|
await using var host = new ChatHost(speech: new FakeSpeechToText(" "));
|
|
host.Bot.Files["voice"] = [1];
|
|
await host.RouteAsync(Updates.VoiceMessage());
|
|
await host.SettleAsync();
|
|
|
|
Assert.Contains("I couldn't make out any words in that voice message.", host.Texts);
|
|
Assert.Empty(host.Faux.Requests);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_voice_note_over_20_MB_gets_the_size_notice_and_no_download()
|
|
{
|
|
var stt = new FakeSpeechToText();
|
|
await using var host = new ChatHost(speech: stt);
|
|
await host.RouteAsync(Updates.VoiceMessage(size: 21L * 1024 * 1024, caption: "about this"));
|
|
await host.SettleAsync();
|
|
|
|
Assert.Contains("That file is 21.0 MB; Telegram lets bots download at most 20 MB. Share a link or a smaller file.", host.Texts);
|
|
Assert.Empty(host.Faux.Requests);
|
|
Assert.Empty(host.Bot.Requests.OfType<GetFileRequest>());
|
|
Assert.Empty(stt.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_failed_voice_download_gets_a_notice_and_no_run()
|
|
{
|
|
var stt = new FakeSpeechToText();
|
|
await using var host = new ChatHost(speech: stt);
|
|
host.Bot.Files["voice"] = [1];
|
|
host.Bot.FailDownloads = true;
|
|
await host.RouteAsync(Updates.VoiceMessage(caption: "about this"));
|
|
await host.SettleAsync();
|
|
|
|
Assert.Contains("I could not download that file.", host.Texts);
|
|
Assert.Empty(host.Faux.Requests);
|
|
Assert.Empty(stt.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_audio_file_is_saved_and_transcribed()
|
|
{
|
|
var time = new FakeTimeProvider(new DateTimeOffset(2026, 9, 26, 10, 0, 0, TimeSpan.Zero));
|
|
var stt = new FakeSpeechToText("welcome to the show");
|
|
await using var host = new ChatHost(time: time, speech: stt);
|
|
host.Bot.Files["audio"] = [4, 5, 6];
|
|
host.Faux.Enqueue(FauxTurn.Say("ok"));
|
|
|
|
await host.RouteAsync(Updates.AudioMessage("talk.mp3", "audio/mpeg", 3));
|
|
await MessageContextChatTests.FlushAsync(host, time, 1);
|
|
await host.SettleAsync();
|
|
|
|
Assert.EndsWith("\n[File received: inbox/2026-09-26-talk.mp3 (audio/mpeg, 3 bytes)]\n\n[Voice message] welcome to the show", Prompt(host), StringComparison.Ordinal);
|
|
Assert.Equal(("audio.mp3", "audio/mpeg"), (stt.Calls.Single().FileName, stt.Calls.Single().MimeType));
|
|
Assert.Equal(new byte[] { 4, 5, 6 }, stt.Calls.Single().Audio);
|
|
Assert.True(File.Exists(Path.Combine(host.WorkspaceOf(), "inbox", "2026-09-26-talk.mp3")));
|
|
Assert.Single(host.Bot.Requests.OfType<GetFileRequest>()); // downloaded once, transcribed from the saved file
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Песня.mp3", "audio/mpeg", "audio.mp3")]
|
|
[InlineData("talk.M4A", "audio/mp4", "audio.m4a")]
|
|
[InlineData(null, "audio/mp4", "audio.m4a")]
|
|
[InlineData(null, "audio/ogg", "audio.ogg")]
|
|
[InlineData("memo.oga", "audio/ogg", "audio.ogg")]
|
|
[InlineData("noext", null, "audio.mp3")]
|
|
public void Audio_goes_to_speech_to_text_under_an_ascii_name_with_a_known_extension(string? fileName, string? mime, string expected) =>
|
|
Assert.Equal(expected, Volcanic.AgenticCore.Telegram.Inbound.InboundMedia.SpeechFileName(fileName, mime));
|
|
|
|
[Fact]
|
|
public async Task Without_speech_to_text_an_audio_file_is_still_saved_and_prompted()
|
|
{
|
|
var time = new FakeTimeProvider(new DateTimeOffset(2026, 9, 26, 10, 0, 0, TimeSpan.Zero));
|
|
await using var host = new ChatHost(time: time);
|
|
host.Bot.Files["audio"] = [4, 5, 6];
|
|
host.Faux.Enqueue(FauxTurn.Say("ok"));
|
|
|
|
await host.RouteAsync(Updates.AudioMessage("talk.mp3", "audio/mpeg", 3));
|
|
await MessageContextChatTests.FlushAsync(host, time, 1);
|
|
await host.SettleAsync();
|
|
|
|
Assert.Contains(NotConfigured, host.Texts);
|
|
Assert.EndsWith("[File received: inbox/2026-09-26-talk.mp3 (audio/mpeg, 3 bytes)]", Prompt(host), StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_voice_note_and_a_text_right_after_it_are_one_prompt_in_order()
|
|
{
|
|
var time = new FakeTimeProvider(new DateTimeOffset(2026, 9, 26, 10, 0, 0, TimeSpan.Zero));
|
|
var stt = new FakeSpeechToText("first the voice") { Gate = new TaskCompletionSource() };
|
|
await using var host = new ChatHost(time: time, speech: stt);
|
|
host.Bot.Files["voice"] = [1];
|
|
host.Faux.Enqueue(FauxTurn.Say("ok"));
|
|
|
|
await host.RouteAsync(Updates.VoiceMessage());
|
|
await host.SendAsync("then the text");
|
|
var chat = await host.ChatAsync();
|
|
await Wait.UntilAsync(() => stt.Calls.Count == 1 && chat.Inbox.Buffered == 1 && chat.Inbox.OpenLeases == 1, "the text composed, the voice held");
|
|
time.Advance(host.Options.InputDebounce * 10); // the debounce window elapses while the voice lease is still open
|
|
await Task.Delay(50);
|
|
Assert.Empty(host.Faux.Requests);
|
|
|
|
stt.Gate.SetResult();
|
|
await MessageContextChatTests.FlushAsync(host, time, 2);
|
|
await host.SettleAsync();
|
|
|
|
var prompt = Prompt(host);
|
|
Assert.InRange(prompt.IndexOf("[Voice message] first the voice", StringComparison.Ordinal), 0, prompt.IndexOf("then the text", StringComparison.Ordinal) - 1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Stop_is_handled_during_a_slow_transcription()
|
|
{
|
|
var stt = new FakeSpeechToText("late") { Gate = new TaskCompletionSource() };
|
|
await using var host = new ChatHost(speech: stt);
|
|
host.Bot.Files["voice"] = [1];
|
|
host.Faux.Enqueue(FauxTurn.Say("too late").WithDelay(TimeSpan.FromSeconds(30)), FauxTurn.Say("Heard you."));
|
|
|
|
await host.SendAsync("take your time");
|
|
var chat = await host.ChatAsync();
|
|
await Wait.UntilAsync(() => chat.Session.IsStreaming, "the run to start");
|
|
|
|
await host.RouteAsync(Updates.VoiceMessage()).WaitAsync(TimeSpan.FromSeconds(2)); // returns while transcription hangs
|
|
await Wait.UntilAsync(() => stt.Calls.Count == 1, "the transcription to start");
|
|
await host.SendAsync("/stop").WaitAsync(TimeSpan.FromSeconds(2));
|
|
await Wait.UntilAsync(() => !chat.Session.IsStreaming, "the run to stop");
|
|
Assert.False(stt.Gate.Task.IsCompleted);
|
|
|
|
stt.Gate.SetResult();
|
|
await host.SettleAsync();
|
|
Assert.Contains("⏹ Stopped.", host.Texts);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_voice_note_does_not_answer_a_pending_question()
|
|
{
|
|
await using var host = new ChatHost(speech: new FakeSpeechToText("Ada Lovelace"));
|
|
host.Bot.Files["voice"] = [1];
|
|
host.Faux.Enqueue(FauxTurn.Say("ok"));
|
|
var chat = await host.ChatAsync();
|
|
var answer = chat.Ui.InputAsync("Your name?", "name", null, CancellationToken.None);
|
|
await Wait.UntilAsync(() => chat.Ui.IsWaitingForInput, "the question");
|
|
|
|
await host.RouteAsync(Updates.VoiceMessage());
|
|
await host.SettleAsync();
|
|
|
|
Assert.True(chat.Ui.IsWaitingForInput);
|
|
Assert.False(answer.IsCompleted);
|
|
Assert.EndsWith("[Voice message] Ada Lovelace", Prompt(host), StringComparison.Ordinal);
|
|
await host.SendAsync("Ada");
|
|
Assert.Equal("Ada", await answer.WaitAsync(TimeSpan.FromSeconds(5)));
|
|
}
|
|
}
|