feat(telegram): OpenAI speech to text service and speech options

Spec 5: the HttpClient registration is named as AddHttpClient<ISpeechToText, OpenAiSpeechToText>() (package Microsoft.Extensions.Http), which is how Program.cs registers it.
This commit is contained in:
EugeneTes
2026-09-26 22:04:27 +02:00
parent ee3049e47a
commit 8758f4b3d1
11 changed files with 282 additions and 2 deletions

View File

@@ -17,6 +17,7 @@ using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Inbound;
using Volcanic.AgenticCore.Telegram.Polling;
using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Speech;
var builder = Host.CreateApplicationBuilder(args);
@@ -39,6 +40,9 @@ if (errors.Count > 0)
builder.Services.AddSingleton(options);
builder.Services.AddSingleton(mcp);
builder.Services.AddSingleton(options.Speech);
if (options.Speech.IsEnabled) // no key, no ISpeechToText: voice notes get the setup notice
builder.Services.AddHttpClient<ISpeechToText, OpenAiSpeechToText>(client => client.Timeout = TimeSpan.FromMinutes(2));
if (calendar is not null) builder.Services.AddSingleton(calendar); // ChatRegistry and BotSetupService take it optionally
builder.Services.AddSingleton<OwnerLoginChannel>();
// The extension is constructed per session through ActivatorUtilities, which injects this. LoginUi resolves
@@ -74,6 +78,7 @@ var logger = host.Services.GetRequiredService<ILoggerFactory>().CreateLogger("Vo
HostProcessHardening.Apply(processEnvironment, logger);
logger.LogInformation("{ProcessEnvironment}", TelegramProcessEnvironment.Describe(processEnvironment));
logger.LogInformation("{Mcp}", mcp.DescribePlaywright());
logger.LogInformation("{Speech}", options.Speech.Describe());
if (ClaudeInstallation.Locate(Environment.GetEnvironmentVariable) is { } claude)
logger.LogInformation("Using Claude Code at {Path}, model {Model}", claude, options.Model);
else

View File

@@ -0,0 +1,11 @@
namespace Volcanic.AgenticCore.Telegram.Speech;
/// <summary>Turns a voice note or audio file into text before a prompt exists. The core only ever sees the text.</summary>
public interface ISpeechToText
{
/// <summary>The trimmed transcript; throws <see cref="SpeechToTextException"/> when the service fails.</summary>
Task<string> TranscribeAsync(ReadOnlyMemory<byte> audio, string fileName, string mimeType, CancellationToken cancellationToken);
}
/// <summary>A transcription failed; the message is fit for the user and never contains the API key.</summary>
public sealed class SpeechToTextException(string message, Exception? inner = null) : Exception(message, inner);

View File

@@ -0,0 +1,49 @@
using System.Net.Http.Headers;
using System.Text.Json;
namespace Volcanic.AgenticCore.Telegram.Speech;
/// <summary>OpenAI's <c>/audio/transcriptions</c> (port of VolcanicSecretary's <c>stt/openai.ts</c>).</summary>
public sealed class OpenAiSpeechToText(HttpClient http, SpeechOptions options) : ISpeechToText
{
private const string Prefix = "OpenAI transcription failed: ";
private const int MaxExcerpt = 200;
public async Task<string> TranscribeAsync(ReadOnlyMemory<byte> audio, string fileName, string mimeType, CancellationToken cancellationToken)
{
using var form = new MultipartFormDataContent();
var file = new ReadOnlyMemoryContent(audio);
file.Headers.ContentType = new MediaTypeHeaderValue(mimeType);
form.Add(file, "file", fileName);
form.Add(new StringContent(options.ResolvedModel), "model");
form.Add(new StringContent("json"), "response_format");
if (!string.IsNullOrWhiteSpace(options.Language)) form.Add(new StringContent(options.Language.Trim()), "language");
using var request = new HttpRequestMessage(HttpMethod.Post, options.BaseUrl.TrimEnd('/') + "/audio/transcriptions") { Content = form };
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", options.ApiKey);
using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var hidden = Hide(body); // hide the key before cutting, so half a key can't leak
var excerpt = hidden.Length > MaxExcerpt ? hidden[..MaxExcerpt] : hidden;
throw new SpeechToTextException($"{Prefix}HTTP {(int)response.StatusCode} {excerpt}".TrimEnd());
}
try
{
using var json = JsonDocument.Parse(body);
if (json.RootElement.ValueKind == JsonValueKind.Object
&& json.RootElement.TryGetProperty("text", out var text) && text.ValueKind == JsonValueKind.String)
return text.GetString()!.Trim();
}
catch (JsonException ex)
{
throw new SpeechToTextException(Prefix + "the response is not JSON.", ex);
}
throw new SpeechToTextException(Prefix + "the response has no text.");
}
private string Hide(string text) =>
string.IsNullOrEmpty(options.ApiKey) ? text : text.Replace(options.ApiKey, "[REDACTED]", StringComparison.Ordinal);
}

View File

@@ -0,0 +1,27 @@
namespace Volcanic.AgenticCore.Telegram.Speech;
/// <summary>The <c>Telegram:Speech</c> section. Speech to text is on exactly when <see cref="ApiKey"/> is set.</summary>
public sealed class SpeechOptions
{
public const string DefaultModel = "gpt-4o-transcribe";
public const string DefaultBaseUrl = "https://api.openai.com/v1";
/// <summary>Falls back to <c>OPENAI_API_KEY</c>. Empty turns speech to text off.</summary>
public string ApiKey { get; set; } = "";
/// <summary>Falls back to <c>STT_MODEL</c>, then <see cref="DefaultModel"/>.</summary>
public string Model { get; set; } = "";
/// <summary>Any OpenAI-compatible transcription endpoint.</summary>
public string BaseUrl { get; set; } = DefaultBaseUrl;
/// <summary>Optional ISO-639-1 hint.</summary>
public string? Language { get; set; }
public bool IsEnabled => !string.IsNullOrWhiteSpace(ApiKey);
public string ResolvedModel => string.IsNullOrWhiteSpace(Model) ? DefaultModel : Model.Trim();
/// <summary>The startup log line (never the key).</summary>
public string Describe() => IsEnabled ? $"Speech to text: {ResolvedModel}" : "Speech to text: off (no OPENAI_API_KEY)";
}

View File

@@ -1,3 +1,5 @@
using Volcanic.AgenticCore.Telegram.Speech;
namespace Volcanic.AgenticCore.Telegram;
/// <summary>The <c>Telegram</c> configuration section.</summary>
@@ -36,6 +38,12 @@ public sealed class TelegramOptions
public TimeSpan ReconnectMaxDelay { get; set; } = TimeSpan.FromSeconds(60);
/// <summary>Image documents up to this size are also shown to the model inline (base64 stays under providers' 5 MB cap).</summary>
public long InlineImageMaxBytes { get; set; } = 3_750_000;
/// <summary>Speech to text for voice notes and audio (<c>Telegram:Speech</c>).</summary>
public SpeechOptions Speech { get; set; } = new();
/// <summary>Reads the environment-variable shortcuts the README documents, when the section leaves them empty.</summary>
public void ApplyEnvironment(Func<string, string?> getEnv)
{
@@ -45,6 +53,13 @@ public sealed class TelegramOptions
foreach (var part in ids.Split([',', ';', ' '], StringSplitOptions.RemoveEmptyEntries))
if (long.TryParse(part, out var id)) AllowedUserIds.Add(id);
}
// Speech: the environment fills only empty settings, and an empty variable counts as unset.
Speech ??= new SpeechOptions();
if (string.IsNullOrWhiteSpace(Speech.ApiKey) && getEnv("OPENAI_API_KEY") is { } key && !string.IsNullOrWhiteSpace(key))
Speech.ApiKey = key.Trim();
if (string.IsNullOrWhiteSpace(Speech.Model) && getEnv("STT_MODEL") is { } model && !string.IsNullOrWhiteSpace(model))
Speech.Model = model.Trim();
}
/// <summary>Configuration problems that must stop the host, empty when valid.</summary>
@@ -62,6 +77,10 @@ public sealed class TelegramOptions
if (ReconnectInitialDelay <= TimeSpan.Zero || ReconnectMaxDelay < ReconnectInitialDelay)
errors.Add("Telegram reconnect delays must be positive with max >= initial.");
if (InputDebounce < TimeSpan.Zero) errors.Add("Telegram:InputDebounce must not be negative.");
if (InlineImageMaxBytes <= 0) errors.Add("Telegram:InlineImageMaxBytes must be positive.");
// On Linux "/v1" parses as an absolute file:// URI; the scheme check rejects it.
if (!Uri.TryCreate(Speech?.BaseUrl, UriKind.Absolute, out var sttUri) || sttUri.Scheme is not ("http" or "https"))
errors.Add("Telegram:Speech:BaseUrl must be an absolute http(s) URL.");
return errors;
}

View File

@@ -8,7 +8,7 @@ public static class TelegramProcessEnvironment
{
public const string AllowVariable = "PROCESS_ENV_ALLOW";
/// <summary>Adds the section's lists, <c>PROCESS_ENV_ALLOW</c>, the bot token and the Google client secret to <paramref name="target"/>.</summary>
/// <summary>Adds the section's lists, <c>PROCESS_ENV_ALLOW</c>, the bot token, the speech API key and the Google client secret to <paramref name="target"/>.</summary>
public static void Configure(ProcessEnvironmentOptions target, TelegramOptions telegram, ProcessEnvironmentSection section, Func<string, string?> getEnv,
GoogleCalendarOptions? calendar = null)
{
@@ -22,6 +22,7 @@ public static class TelegramProcessEnvironment
target.Allow.AddRange(allow.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
target.HardenHostProcess = section.HardenHostProcess;
if (!string.IsNullOrWhiteSpace(telegram.BotToken)) target.SecretValues.Add(telegram.BotToken);
if (!string.IsNullOrWhiteSpace(telegram.Speech?.ApiKey)) target.SecretValues.Add(telegram.Speech.ApiKey);
if (!string.IsNullOrWhiteSpace(calendar?.ClientSecret)) target.SecretValues.Add(calendar.ClientSecret);
}

View File

@@ -9,6 +9,7 @@
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/Volcanic.AgenticCore.Extensions.GoogleCalendar.csproj" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Telegram.Bot" Version="22.10.3.1" />
</ItemGroup>
<ItemGroup>