forked from admin/VolcanicAgenticCore
- A speech or download timeout (TaskCanceledException nobody asked for) is a failure with the user notice, not a silent shutdown; OpenAiSpeechToText maps it to "the request timed out."; UpdateRouter only treats the caller's cancellation as shutdown. - send: WorkspacePaths resolves the real path component by component (realpath), closing the directory-link-inside-target bypass; `.` reports a directory. - Image documents are attached inline only when their magic bytes are JPEG/PNG/GIF/WebP, under the detected type. - Audio goes to STT as an ASCII audio.<ext> name. - MCP Browser section mentions send only when it is active. - RunInOrderAsync takes a cancellation token; queued work is skipped once disposal gives up; SendReplyAsync stays private behind ReplyInOrderAsync. - Speech HttpClient handler recycles pooled connections; BuiltInTools.Send(); IExtensionHost.Channel is abstract; WorkspacePaths in its own file. - Tests: timeouts, symlink chains (Linux), sniffing, channel delivery failures, queued-work cancellation, gated voice ordering. Spec and docs updated.
122 lines
6.0 KiB
C#
122 lines
6.0 KiB
C#
using System.Net;
|
|
using System.Text;
|
|
using Volcanic.AgenticCore.Telegram.Speech;
|
|
|
|
namespace Volcanic.AgenticCore.Telegram.Tests;
|
|
|
|
public sealed class OpenAiSpeechToTextTests
|
|
{
|
|
private const string Key = "sk-test-secret-key-123";
|
|
|
|
private sealed record Part(string? FileName, string? ContentType, string Text);
|
|
|
|
private sealed class StubHandler(HttpStatusCode status, string body, string mediaType = "application/json") : HttpMessageHandler
|
|
{
|
|
public HttpRequestMessage? Request { get; private set; }
|
|
public Dictionary<string, Part> Parts { get; } = [];
|
|
|
|
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
Request = request;
|
|
foreach (var part in (MultipartFormDataContent)request.Content!)
|
|
{
|
|
var disposition = part.Headers.ContentDisposition!;
|
|
Parts[disposition.Name!.Trim('"')] = new Part(disposition.FileName?.Trim('"'), part.Headers.ContentType?.MediaType,
|
|
await part.ReadAsStringAsync(cancellationToken));
|
|
}
|
|
return new HttpResponseMessage(status) { Content = new StringContent(body, Encoding.UTF8, mediaType) };
|
|
}
|
|
}
|
|
|
|
private static (OpenAiSpeechToText Stt, StubHandler Handler) Create(HttpStatusCode status, string body, string? language = null)
|
|
{
|
|
var handler = new StubHandler(status, body);
|
|
var options = new SpeechOptions { ApiKey = Key, Language = language, BaseUrl = "https://stt.example/v1/" };
|
|
return (new OpenAiSpeechToText(new HttpClient(handler), options), handler);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Posts_a_multipart_form_with_file_model_and_response_format()
|
|
{
|
|
var (stt, handler) = Create(HttpStatusCode.OK, "{\"text\":\"hello\"}");
|
|
await stt.TranscribeAsync(new byte[] { 1, 2, 3 }, "voice.ogg", "audio/ogg", CancellationToken.None);
|
|
|
|
Assert.Equal(HttpMethod.Post, handler.Request!.Method);
|
|
Assert.Equal("https://stt.example/v1/audio/transcriptions", handler.Request.RequestUri!.ToString());
|
|
Assert.Equal("Bearer", handler.Request.Headers.Authorization!.Scheme);
|
|
Assert.Equal(Key, handler.Request.Headers.Authorization.Parameter);
|
|
Assert.Equal(("voice.ogg", "audio/ogg"), (handler.Parts["file"].FileName, handler.Parts["file"].ContentType));
|
|
Assert.Equal("gpt-4o-transcribe", handler.Parts["model"].Text);
|
|
Assert.Equal("json", handler.Parts["response_format"].Text);
|
|
Assert.False(handler.Parts.ContainsKey("language"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Language_is_sent_only_when_configured()
|
|
{
|
|
var (stt, handler) = Create(HttpStatusCode.OK, "{\"text\":\"hallo\"}", language: "de");
|
|
await stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None);
|
|
Assert.Equal("de", handler.Parts["language"].Text);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_transcript_is_trimmed()
|
|
{
|
|
var (stt, _) = Create(HttpStatusCode.OK, "{\"text\":\" hello there \\n\"}");
|
|
Assert.Equal("hello there", await stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_non_success_status_names_the_status_and_hides_the_key()
|
|
{
|
|
var body = "{\"error\":{\"message\":\"Incorrect API key provided: " + Key + "\"}}" + new string('x', 300);
|
|
var (stt, _) = Create(HttpStatusCode.Unauthorized, body);
|
|
var ex = await Assert.ThrowsAsync<SpeechToTextException>(() => stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None));
|
|
Assert.StartsWith("OpenAI transcription failed: HTTP 401 ", ex.Message, StringComparison.Ordinal);
|
|
Assert.DoesNotContain(Key, ex.Message, StringComparison.Ordinal);
|
|
Assert.True(ex.Message.Length <= "OpenAI transcription failed: HTTP 401 ".Length + 200);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_non_json_body_has_its_own_message()
|
|
{
|
|
var (stt, _) = Create(HttpStatusCode.OK, "<html>gateway</html>");
|
|
var ex = await Assert.ThrowsAsync<SpeechToTextException>(() => stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None));
|
|
Assert.Equal("OpenAI transcription failed: the response is not JSON.", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_missing_text_has_its_own_message()
|
|
{
|
|
var (stt, _) = Create(HttpStatusCode.OK, "{\"segments\":[]}");
|
|
var ex = await Assert.ThrowsAsync<SpeechToTextException>(() => stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None));
|
|
Assert.Equal("OpenAI transcription failed: the response has no text.", ex.Message);
|
|
}
|
|
|
|
private sealed class HangingHandler : HttpMessageHandler
|
|
{
|
|
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
await Task.Delay(Timeout.Infinite, cancellationToken);
|
|
throw new InvalidOperationException("unreachable");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_client_timeout_is_a_speech_failure_not_a_cancellation()
|
|
{
|
|
var http = new HttpClient(new HangingHandler()) { Timeout = TimeSpan.FromMilliseconds(50) };
|
|
var stt = new OpenAiSpeechToText(http, new SpeechOptions { ApiKey = Key });
|
|
var ex = await Assert.ThrowsAsync<SpeechToTextException>(() => stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None));
|
|
Assert.Equal("OpenAI transcription failed: the request timed out.", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_callers_cancellation_still_cancels()
|
|
{
|
|
var stt = new OpenAiSpeechToText(new HttpClient(new HangingHandler()), new SpeechOptions { ApiKey = Key });
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", cts.Token));
|
|
}
|
|
}
|