forked from admin/VolcanicAgenticCore
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:
@@ -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
|
||||
|
||||
11
hosts/Volcanic.AgenticCore.Telegram/Speech/ISpeechToText.cs
Normal file
11
hosts/Volcanic.AgenticCore.Telegram/Speech/ISpeechToText.cs
Normal 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);
|
||||
@@ -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);
|
||||
}
|
||||
27
hosts/Volcanic.AgenticCore.Telegram/Speech/SpeechOptions.cs
Normal file
27
hosts/Volcanic.AgenticCore.Telegram/Speech/SpeechOptions.cs
Normal 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)";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user