From 7fef3ce94e71ff2e9e4f2316425b124afb6d7f6c Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Wed, 14 Oct 2026 13:07:36 +0000 Subject: [PATCH] feat(telegram): ElevenLabs text to speech for the voice tool ElevenLabs speaks for the voice tool when ELEVENLABS_API_KEY and ELEVENLABS_VOICE_ID are set (TTS_PROVIDER=elevenlabs|openai picks, the other is the fallback). Opus output (opus_48000_64) goes to Telegram as is; voice names resolve through /v2/voices; instructions become audio tags on eleven_v3 and are ignored on other models. The key is redacted from tool output and never reaches agent processes. --- CLAUDE.md | 3 +- docker-compose.yml | 4 + docs/docker.md | 4 + docs/telegram-bot.md | 13 +- .../Chats/VoiceExtension.cs | 9 +- .../Volcanic.AgenticCore.Telegram/Program.cs | 19 ++- .../Speech/ElevenLabsOptions.cs | 44 +++++ .../Speech/ElevenLabsTextToSpeech.cs | 152 ++++++++++++++++++ .../Speech/ITextToSpeech.cs | 3 + .../Speech/OpenAiTextToSpeech.cs | 4 + .../Speech/SpeechOptions.cs | 76 ++++++++- .../TelegramOptions.cs | 8 +- .../TelegramProcessEnvironment.cs | 3 +- .../ElevenLabsTextToSpeechTests.cs | 138 ++++++++++++++++ .../FormattingAndOptionsTests.cs | 51 +++++- .../ProcessEnvironmentTests.cs | 42 +++++ .../Support/FakeTextToSpeech.cs | 2 + 17 files changed, 554 insertions(+), 21 deletions(-) create mode 100644 hosts/Volcanic.AgenticCore.Telegram/Speech/ElevenLabsOptions.cs create mode 100644 hosts/Volcanic.AgenticCore.Telegram/Speech/ElevenLabsTextToSpeech.cs create mode 100644 tests/Volcanic.AgenticCore.Telegram.Tests/ElevenLabsTextToSpeechTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 47439f8..70a9924 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,7 +130,8 @@ message in update order, `InboundComposer` builds its part (reply/forward header photo) in the background, and the inbox flushes a batch `InputDebounce` (600 ms) after the last message as one prompt with a local-time header. Files, audio and voice are saved to `/inbox/` (voice transcribed by `Speech/OpenAiSpeechToText` when `OPENAI_API_KEY` is set; the same key gives each chat the `voice` tool, -`Chats/VoiceExtension` over `Speech/OpenAiTextToSpeech`, which sends OGG/Opus voice notes) through `InboundComposer`'s content steps +`Chats/VoiceExtension` over `Speech/OpenAiTextToSpeech`, or `Speech/ElevenLabsTextToSpeech` when `ELEVENLABS_API_KEY` and +`ELEVENLABS_VOICE_ID` are set (`TTS_PROVIDER` picks), which sends OGG/Opus voice notes) through `InboundComposer`'s content steps (`Inbound/InboundMedia`), and `Chats/TelegramChatChannel` implements `IChatChannel` for the core `send` tool through `ChatRenderer.RunInOrderAsync`. The first allowed id is the "owner": `OwnerLoginChannel` sends Claude Code re-login prompts to that chat. It resolves lazily to break the DI cycle ChatRegistry → session factory → extension → ChatRegistry. diff --git a/docker-compose.yml b/docker-compose.yml index 2ef3194..d6da04c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,10 @@ services: - STT_MODEL=${STT_MODEL:-} - TTS_MODEL=${TTS_MODEL:-} - TTS_VOICE=${TTS_VOICE:-} + - TTS_PROVIDER=${TTS_PROVIDER:-} + - ELEVENLABS_API_KEY=${ELEVENLABS_API_KEY:-} + - ELEVENLABS_VOICE_ID=${ELEVENLABS_VOICE_ID:-} + - ELEVENLABS_MODEL=${ELEVENLABS_MODEL:-} - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-} # group topic triggers via Jev (group triggers spec) - TRIGGER_MODEL=${TRIGGER_MODEL:-} - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} # Google Calendar (calendar spec) diff --git a/docs/docker.md b/docs/docker.md index 89c6a35..649c692 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -78,6 +78,10 @@ The compose file maps them into the container. | `STT_MODEL` | `gpt-4o-transcribe` | Transcription model | Yes | | `TTS_MODEL` | `gpt-4o-mini-tts` | Speech model of the `voice` tool | Yes | | `TTS_VOICE` | `nova` | Default voice of the `voice` tool | Yes | +| `TTS_PROVIDER` | — (ElevenLabs if configured) | `elevenlabs` or `openai`: which service the `voice` tool prefers; the other is the fallback | Yes | +| `ELEVENLABS_API_KEY` | — | With `ELEVENLABS_VOICE_ID`, makes ElevenLabs speak for the `voice` tool | No, and redacted | +| `ELEVENLABS_VOICE_ID` | — | Default ElevenLabs voice (id, or a voice name in the account) | Yes | +| `ELEVENLABS_MODEL` | `eleven_multilingual_v2` | ElevenLabs model; `eleven_v3` also reads `instructions` as audio tags | Yes | | `OPENROUTER_API_KEY` | — | Turns on group watching with topic triggers, classified by Jev through OpenRouter ([telegram-bot.md](telegram-bot.md#groups-and-triggers)) | No, and redacted | | `TRIGGER_MODEL` | `typesafe/jev-1.13` | The Jev model triggers use | Yes | | `GOOGLE_CLIENT_ID` | — | With the secret, turns on Google Calendar ([google-calendar.md](google-calendar.md)) | Yes | diff --git a/docs/telegram-bot.md b/docs/telegram-bot.md index 8de269e..9e3e040 100644 --- a/docs/telegram-bot.md +++ b/docs/telegram-bot.md @@ -48,7 +48,12 @@ settings use the `GoogleCalendar` section, the scheduler the `Scheduler` section | `STT_MODEL` / `Telegram:Speech:Model` | `gpt-4o-transcribe` | `gpt-4o-mini-transcribe` and `whisper-1` also work | | `TTS_MODEL` / `Telegram:Speech:TtsModel` | `gpt-4o-mini-tts` | Speech model of the `voice` tool; `tts-1` and `tts-1-hd` also work (they ignore `instructions`) | | `TTS_VOICE` / `Telegram:Speech:TtsVoice` | `nova` | Default voice of the `voice` tool | -| `Telegram:Speech:TtsInstructions` | — | Default delivery instructions (tone, pace) for the `voice` tool | +| `Telegram:Speech:TtsInstructions` | — | Default delivery instructions (tone, pace) for the `voice` tool (OpenAI only) | +| `TTS_PROVIDER` / `Telegram:Speech:TtsProvider` | — | `elevenlabs` or `openai`. Empty prefers ElevenLabs. The preferred service is used when configured, else the other | +| `ELEVENLABS_API_KEY` / `Telegram:Speech:ElevenLabs:ApiKey` | — | With a voice id, turns on ElevenLabs for the `voice` tool. Hidden from `bash` and redacted from tool output | +| `ELEVENLABS_VOICE_ID` / `Telegram:Speech:ElevenLabs:VoiceId` | — | Default ElevenLabs voice: a voice id, or the name of a voice in the account | +| `ELEVENLABS_MODEL` / `Telegram:Speech:ElevenLabs:Model` | `eleven_multilingual_v2` | ElevenLabs model; `eleven_v3` reads `instructions` as audio tags, other models ignore them | +| `Telegram:Speech:ElevenLabs:OutputFormat` | `opus_48000_64` | Must be an `opus_48000_*` format (OGG/Opus) | | `Telegram:Speech:BaseUrl` | `https://api.openai.com/v1` | Any OpenAI-compatible audio endpoint (transcription and speech) | | `Telegram:Speech:Language` | — | Optional ISO-639-1 hint, e.g. `de` | | `OPENROUTER_API_KEY` / `Telegram:Triggers:ApiKey` | — | Turns on [groups and triggers](#groups-and-triggers). Hidden from `bash` and redacted from tool output | @@ -170,6 +175,12 @@ question in that chat (for example from an extension) replaces the pending promp text over 1500 characters is split at paragraph, sentence or word boundaries into several voice messages. Longer text is refused. Every part is synthesized before the first is sent, so an API error (returned to the agent as text, key hidden) never leaves half a message; synthesis is retried like transcription. + **ElevenLabs** takes over when `ELEVENLABS_API_KEY` and `ELEVENLABS_VOICE_ID` are set (and `TTS_PROVIDER` isn't + `openai`); it needs no OpenAI key. It calls `POST /v1/text-to-speech/{voice_id}?output_format=opus_48000_64` with + `eleven_multilingual_v2` by default, so the audio is OGG/Opus already. The tool's `voice` is a voice id or the name + of a voice in the account (looked up once through `/v2/voices`); `instructions` become audio tags (`[warm]`) on + `eleven_v3` and are ignored on other models. Errors such as `HTTP 401 quota_exceeded: …` or `HTTP 429 …` come back + as text; 429 and 5xx are retried. The tool description follows the active service. The startup log line names it. - Telegram lets bots download at most 20 MB. A bigger file is refused with a note, and nothing reaches the agent. - A message with an attachment is never a command and never answers a pending question: its caption is prompt text. - Anything else (stickers, video notes, locations, polls) gets `I can read text, photos, files and voice messages.` diff --git a/hosts/Volcanic.AgenticCore.Telegram/Chats/VoiceExtension.cs b/hosts/Volcanic.AgenticCore.Telegram/Chats/VoiceExtension.cs index e221cc0..a8d6220 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Chats/VoiceExtension.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Chats/VoiceExtension.cs @@ -10,10 +10,10 @@ namespace Volcanic.AgenticCore.Telegram.Chats; /// Arguments of voice. public sealed record VoiceArgs( [property: Description("What to say, as plain spoken text in any language (no Markdown, links or emoji)")] string Text, - [property: Description("Optional OpenAI voice: nova (default, female), marin, coral, shimmer, cedar, alloy, ash, ballad, echo, fable, onyx, sage, verse")] string? Voice = null, - [property: Description("Optional delivery instructions in English, e.g. \"warm and calm, unhurried\" or \"cheerful, upbeat\"")] string? Instructions = null); + [property: Description("Optional voice; the tool description says which values work")] string? Voice = null, + [property: Description("Optional delivery style in English; the tool description says how it is used")] string? Instructions = null); -/// Host extension per chat: the voice tool, which speaks text with OpenAI and sends it as Telegram voice notes. +/// Host extension per chat: the voice tool, which speaks text with the configured service (ElevenLabs or OpenAI) and sends it as Telegram voice notes. public sealed class VoiceExtension(ITextToSpeech speech, TelegramChatChannel channel) : IExtension { public const string ExtensionName = "telegram-voice"; @@ -34,7 +34,8 @@ public sealed class VoiceExtension(ITextToSpeech speech, TelegramChatChannel cha ToolName, $"Speak text aloud and send it to the user in this chat as a Telegram voice message. Up to {MaxTextLength} characters per call; " + $"text over {MaxPartLength} characters is split at sentence boundaries into several voice messages, sent in order. " + - "Longer text is refused: shorten it, or send it as text. Errors come back as text, so you can tell the user.", + "Longer text is refused: shorten it, or send it as text. Errors come back as text, so you can tell the user. " + + speech.ArgumentGuide, (args, _, ct) => SpeakAsync(args, ct), promptSnippet: "voice: send text to the user as a spoken voice message", promptGuidelines: diff --git a/hosts/Volcanic.AgenticCore.Telegram/Program.cs b/hosts/Volcanic.AgenticCore.Telegram/Program.cs index 70a78fb..cf27340 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Program.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Program.cs @@ -69,10 +69,21 @@ if (options.Speech.IsEnabled) // no key, no ISpeechToText: voice notes get the s builder.Services.AddHttpClient(client => client.Timeout = TimeSpan.FromMinutes(2)) .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) }) .SetHandlerLifetime(Timeout.InfiniteTimeSpan); -if (options.Speech.IsEnabled) // the voice tool; ChatRegistry (a singleton) keeps this client too, so the same handler setup - builder.Services.AddHttpClient(client => client.Timeout = TimeSpan.FromMinutes(2)) - .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) }) - .SetHandlerLifetime(Timeout.InfiniteTimeSpan); +// The voice tool: ElevenLabs when configured (or chosen with TTS_PROVIDER), else OpenAI. ChatRegistry (a singleton) keeps +// this client too, so the same handler setup. +switch (options.Speech.ResolvedTtsProvider) +{ + case TextToSpeechProvider.OpenAi: + builder.Services.AddHttpClient(client => client.Timeout = TimeSpan.FromMinutes(2)) + .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) }) + .SetHandlerLifetime(Timeout.InfiniteTimeSpan); + break; + case TextToSpeechProvider.ElevenLabs: + builder.Services.AddHttpClient(client => client.Timeout = TimeSpan.FromMinutes(2)) + .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) }) + .SetHandlerLifetime(Timeout.InfiniteTimeSpan); + break; +} if (calendar is not null) builder.Services.AddSingleton(calendar); // ChatRegistry and BotSetupService take it optionally builder.Services.AddSingleton(); // The extension is constructed per session through ActivatorUtilities, which injects this. LoginUi resolves diff --git a/hosts/Volcanic.AgenticCore.Telegram/Speech/ElevenLabsOptions.cs b/hosts/Volcanic.AgenticCore.Telegram/Speech/ElevenLabsOptions.cs new file mode 100644 index 0000000..8f4496a --- /dev/null +++ b/hosts/Volcanic.AgenticCore.Telegram/Speech/ElevenLabsOptions.cs @@ -0,0 +1,44 @@ +namespace Volcanic.AgenticCore.Telegram.Speech; + +/// +/// The Telegram:Speech:ElevenLabs section: ElevenLabs text to speech for the voice tool. Configured exactly +/// when both and are set. +/// +public sealed class ElevenLabsOptions +{ + public const string DefaultBaseUrl = "https://api.elevenlabs.io"; + public const string DefaultModel = "eleven_multilingual_v2"; + + /// OGG/Opus, which Telegram shows as a voice note. + public const string DefaultOutputFormat = "opus_48000_64"; + + /// Falls back to ELEVENLABS_API_KEY. + public string ApiKey { get; set; } = ""; + + /// Falls back to ELEVENLABS_VOICE_ID. A voice id, or the name of a voice in the account's library. + public string VoiceId { get; set; } = ""; + + /// Falls back to ELEVENLABS_MODEL, then . + public string Model { get; set; } = ""; + + public string BaseUrl { get; set; } = DefaultBaseUrl; + + /// An opus_48000_* format; others aren't OGG/Opus and are refused. + public string OutputFormat { get; set; } = DefaultOutputFormat; + + public bool IsConfigured => !string.IsNullOrWhiteSpace(ApiKey) && !string.IsNullOrWhiteSpace(VoiceId); + + public string ResolvedModel => string.IsNullOrWhiteSpace(Model) ? DefaultModel : Model.Trim(); + + /// Only v3 models read audio tags such as [whispers]; older models would speak them aloud. + public bool SupportsAudioTags => ResolvedModel.StartsWith("eleven_v3", StringComparison.OrdinalIgnoreCase); + + /// Reads the environment shortcuts; the environment fills only empty settings, and an empty variable counts as unset. + public void ApplyEnvironment(Func getEnv) + { + ArgumentNullException.ThrowIfNull(getEnv); + if (string.IsNullOrWhiteSpace(ApiKey) && getEnv("ELEVENLABS_API_KEY") is { } key && !string.IsNullOrWhiteSpace(key)) ApiKey = key.Trim(); + if (string.IsNullOrWhiteSpace(VoiceId) && getEnv("ELEVENLABS_VOICE_ID") is { } voice && !string.IsNullOrWhiteSpace(voice)) VoiceId = voice.Trim(); + if (string.IsNullOrWhiteSpace(Model) && getEnv("ELEVENLABS_MODEL") is { } model && !string.IsNullOrWhiteSpace(model)) Model = model.Trim(); + } +} diff --git a/hosts/Volcanic.AgenticCore.Telegram/Speech/ElevenLabsTextToSpeech.cs b/hosts/Volcanic.AgenticCore.Telegram/Speech/ElevenLabsTextToSpeech.cs new file mode 100644 index 0000000..3546a37 --- /dev/null +++ b/hosts/Volcanic.AgenticCore.Telegram/Speech/ElevenLabsTextToSpeech.cs @@ -0,0 +1,152 @@ +using System.Collections.Concurrent; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Volcanic.AgenticCore.Telegram.Speech; + +/// +/// ElevenLabs' /v1/text-to-speech/{voice_id} with an opus_48000_* output format, which is OGG/Opus and so +/// shows in Telegram as a voice note. A voice name is looked up once in the account's voices (/v2/voices). +/// +public sealed partial class ElevenLabsTextToSpeech(HttpClient http, SpeechOptions options) : ITextToSpeech +{ + private const string Prefix = "ElevenLabs speech synthesis failed: "; + private const int MaxExcerpt = 200; + + private readonly ConcurrentDictionary _voiceIds = new(StringComparer.OrdinalIgnoreCase); + + private ElevenLabsOptions Settings => options.ElevenLabs; + + public string ArgumentGuide => + "voice: an ElevenLabs voice id (20 letters and digits) or the name of a voice in the account's library; the configured voice by default. " + + (Settings.SupportsAudioTags + ? "instructions: delivery as ElevenLabs v3 audio tags in English, e.g. \"[warm] [calm]\", \"[excited]\", \"[whispers]\"; plain words become one tag." + : $"instructions: ignored by the current model ({Settings.ResolvedModel}); the voice's own style applies."); + + public async Task SynthesizeAsync(string text, string? voice, string? instructions, CancellationToken cancellationToken) + { + var voiceId = await ResolveVoiceAsync(string.IsNullOrWhiteSpace(voice) ? Settings.VoiceId : voice, cancellationToken).ConfigureAwait(false); + var input = Tags(instructions) is { } tags ? $"{tags} {text}" : text; + for (var attempt = 1; ; attempt++) + { + try + { + return await SynthesizeOnceAsync(input, voiceId, cancellationToken).ConfigureAwait(false); + } + catch (TextToSpeechException ex) when (ex.IsTransient && attempt < SpeechOptions.MaxAttempts) + { + await Task.Delay(options.RetryDelay * (1 << (attempt - 1)), cancellationToken).ConfigureAwait(false); + } + } + } + + // v3 reads "[tag]" as delivery; older models would speak it, so they get nothing. + private string? Tags(string? instructions) + { + if (!Settings.SupportsAudioTags || string.IsNullOrWhiteSpace(instructions)) return null; + var tone = instructions.Trim(); + return tone.Contains('[', StringComparison.Ordinal) ? tone : $"[{tone}]"; + } + + private async Task SynthesizeOnceAsync(string text, string voiceId, CancellationToken cancellationToken) + { + var url = $"{BaseUrl}/v1/text-to-speech/{Uri.EscapeDataString(voiceId)}?output_format={Uri.EscapeDataString(Settings.OutputFormat.Trim())}"; + using var request = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = JsonContent.Create(new Dictionary { ["text"] = text, ["model_id"] = Settings.ResolvedModel }), + }; + var audio = await SendAsync(request, cancellationToken).ConfigureAwait(false); + if (audio.Length == 0) throw new TextToSpeechException(Prefix + "the response is empty."); + if (audio.Length < 4 || audio[0] != 'O' || audio[1] != 'g' || audio[2] != 'g' || audio[3] != 'S') + throw new TextToSpeechException(Prefix + $"the audio is not OGG/Opus (output format {Settings.OutputFormat}); use an opus_48000_* format."); + return audio; + } + + private async Task ResolveVoiceAsync(string voice, CancellationToken cancellationToken) + { + voice = voice.Trim(); + if (VoiceIdShape().IsMatch(voice)) return voice; + if (_voiceIds.TryGetValue(voice, out var cached)) return cached; + + using var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/v2/voices?page_size=100&search={Uri.EscapeDataString(voice)}"); + var body = await SendAsync(request, cancellationToken).ConfigureAwait(false); + var voices = new List<(string Id, string Name)>(); + try + { + using var json = JsonDocument.Parse(body); + if (json.RootElement.TryGetProperty("voices", out var list) && list.ValueKind == JsonValueKind.Array) + foreach (var item in list.EnumerateArray()) + if (item.TryGetProperty("voice_id", out var id) && item.TryGetProperty("name", out var name) + && id.GetString() is { Length: > 0 } idText && name.GetString() is { } nameText) + voices.Add((idText, nameText.Trim())); + } + catch (JsonException ex) + { + throw new TextToSpeechException(Prefix + "the voice list is not valid JSON.", ex); + } + + // An exact name first, then "Name - description" style names, then a single search hit. + var match = voices.Where(v => string.Equals(v.Name, voice, StringComparison.OrdinalIgnoreCase)).Select(v => v.Id).FirstOrDefault() + ?? voices.Where(v => v.Name.StartsWith(voice, StringComparison.OrdinalIgnoreCase) + && v.Name[voice.Length..].TrimStart() is [ '-' or '(' or ',' or '|', ..]) + .Select(v => v.Id).FirstOrDefault() + ?? (voices.Count == 1 ? voices[0].Id : null); + if (match is null) + throw new TextToSpeechException(voices.Count == 0 + ? $"{Prefix}no voice named \"{voice}\" in the ElevenLabs account. Use a voice id or a name from the account's voices." + : $"{Prefix}\"{voice}\" matches several voices: {string.Join(", ", voices.Take(10).Select(v => $"{v.Name} ({v.Id})"))}. Use the voice id."); + _voiceIds[voice] = match; + return match; + } + + private string BaseUrl => Settings.BaseUrl.TrimEnd('/'); + + private async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + request.Headers.Add("xi-api-key", Settings.ApiKey); + try + { + using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false); + if (response.IsSuccessStatusCode) return await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); + var error = Hide(Explain(await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false))); // hide before cutting + var excerpt = error.Length > MaxExcerpt ? error[..MaxExcerpt] : error; + var status = (int)response.StatusCode; + throw new TextToSpeechException($"{Prefix}HTTP {status} {excerpt}".TrimEnd()) { IsTransient = status is 408 or 429 or >= 500 }; + } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) // HttpClient.Timeout + { + throw new TextToSpeechException(Prefix + "the request timed out.", ex) { IsTransient = true }; + } + catch (HttpRequestException ex) + { + throw new TextToSpeechException(Prefix + Hide(ex.Message), ex) { IsTransient = true }; + } + } + + // ElevenLabs errors look like {"detail":{"status":"quota_exceeded","message":"..."}}; show "status: message". + private static string Explain(string body) + { + try + { + using var json = JsonDocument.Parse(body); + if (json.RootElement.ValueKind == JsonValueKind.Object && json.RootElement.TryGetProperty("detail", out var detail) + && detail.ValueKind == JsonValueKind.Object) + { + var status = detail.TryGetProperty("status", out var s) && s.ValueKind == JsonValueKind.String ? s.GetString() : null; + var message = detail.TryGetProperty("message", out var m) && m.ValueKind == JsonValueKind.String ? m.GetString() : null; + if (status is not null || message is not null) return string.Join(": ", new[] { status, message }.Where(p => !string.IsNullOrWhiteSpace(p))); + } + } + catch (JsonException) + { + } + return body; + } + + private string Hide(string text) => + string.IsNullOrEmpty(Settings.ApiKey) ? text : text.Replace(Settings.ApiKey, "[REDACTED]", StringComparison.Ordinal); + + [GeneratedRegex("^[A-Za-z0-9]{20}$")] + private static partial Regex VoiceIdShape(); +} diff --git a/hosts/Volcanic.AgenticCore.Telegram/Speech/ITextToSpeech.cs b/hosts/Volcanic.AgenticCore.Telegram/Speech/ITextToSpeech.cs index 8c4272c..5e8ce09 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Speech/ITextToSpeech.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Speech/ITextToSpeech.cs @@ -8,6 +8,9 @@ public interface ITextToSpeech /// configured defaults when set. Throws when the service fails. /// Task SynthesizeAsync(string text, string? voice, string? instructions, CancellationToken cancellationToken); + + /// What the voice and instructions arguments mean for this service, for the tool description. + string ArgumentGuide { get; } } /// Speech synthesis failed; the message is fit for the user and never contains the API key. diff --git a/hosts/Volcanic.AgenticCore.Telegram/Speech/OpenAiTextToSpeech.cs b/hosts/Volcanic.AgenticCore.Telegram/Speech/OpenAiTextToSpeech.cs index 5680688..bb65423 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Speech/OpenAiTextToSpeech.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Speech/OpenAiTextToSpeech.cs @@ -9,6 +9,10 @@ public sealed class OpenAiTextToSpeech(HttpClient http, SpeechOptions options) : private const string Prefix = "OpenAI speech synthesis failed: "; private const int MaxExcerpt = 200; + public string ArgumentGuide => + $"voice: an OpenAI voice, {options.ResolvedTtsVoice} by default; nova (female), marin, coral, shimmer, cedar, alloy, ash, ballad, echo, fable, onyx, sage, verse. " + + "instructions: delivery in English, e.g. \"warm and calm, unhurried\" or \"cheerful, upbeat\"."; + public async Task SynthesizeAsync(string text, string? voice, string? instructions, CancellationToken cancellationToken) { for (var attempt = 1; ; attempt++) diff --git a/hosts/Volcanic.AgenticCore.Telegram/Speech/SpeechOptions.cs b/hosts/Volcanic.AgenticCore.Telegram/Speech/SpeechOptions.cs index 03b969f..e8388a0 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/Speech/SpeechOptions.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/Speech/SpeechOptions.cs @@ -1,8 +1,16 @@ namespace Volcanic.AgenticCore.Telegram.Speech; +/// Which service speaks for the voice tool. +public enum TextToSpeechProvider +{ + None, + OpenAi, + ElevenLabs, +} + /// -/// The Telegram:Speech section. Speech to text (voice notes in) and text to speech (the voice tool) are on -/// exactly when is set. +/// The Telegram:Speech section. Speech to text (voice notes in) is on exactly when is set; +/// text to speech (the voice tool) uses when it is configured, else OpenAI with the same key. /// public sealed class SpeechOptions { @@ -26,6 +34,15 @@ public sealed class SpeechOptions /// Default delivery instructions (tone, pace); the tool's instructions argument overrides them. Ignored by tts-1. public string? TtsInstructions { get; set; } + /// + /// Falls back to TTS_PROVIDER: elevenlabs or openai. Empty prefers ElevenLabs. The preferred + /// provider is used when configured, else the other one. + /// + public string TtsProvider { get; set; } = ""; + + /// ElevenLabs text to speech (Telegram:Speech:ElevenLabs). + public ElevenLabsOptions ElevenLabs { get; set; } = new(); + /// Any OpenAI-compatible audio endpoint (transcriptions and speech). public string BaseUrl { get; set; } = DefaultBaseUrl; @@ -46,8 +63,55 @@ public sealed class SpeechOptions public string ResolvedTtsVoice => string.IsNullOrWhiteSpace(TtsVoice) ? DefaultTtsVoice : TtsVoice.Trim(); - /// The startup log line (never the key). - public string Describe() => IsEnabled - ? $"Speech to text: {ResolvedModel}; text to speech: {ResolvedTtsModel}, voice {ResolvedTtsVoice}" - : "Speech to text and text to speech: off (no OPENAI_API_KEY)"; + /// The service behind the voice tool, or when neither is configured. + public TextToSpeechProvider ResolvedTtsProvider + { + get + { + var elevenLabs = ElevenLabs?.IsConfigured == true; + var preferOpenAi = string.Equals(TtsProvider?.Trim(), "openai", StringComparison.OrdinalIgnoreCase); + return (preferOpenAi, IsEnabled, elevenLabs) switch + { + (true, true, _) => TextToSpeechProvider.OpenAi, + (false, _, true) => TextToSpeechProvider.ElevenLabs, + (_, true, _) => TextToSpeechProvider.OpenAi, + (_, _, true) => TextToSpeechProvider.ElevenLabs, + _ => TextToSpeechProvider.None, + }; + } + } + + /// Settings problems that must stop the host, added to . + public void Validate(List errors) + { + ArgumentNullException.ThrowIfNull(errors); + // On Linux "/v1" parses as an absolute file:// URI; the scheme check rejects it. + if (!Uri.TryCreate(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."); + if (!string.IsNullOrWhiteSpace(TtsProvider) && TtsProvider.Trim().ToLowerInvariant() is not ("openai" or "elevenlabs")) + errors.Add("TTS_PROVIDER (Telegram:Speech:TtsProvider) must be 'elevenlabs' or 'openai'."); + if (!Uri.TryCreate(ElevenLabs?.BaseUrl, UriKind.Absolute, out var elevenUri) || elevenUri.Scheme is not ("http" or "https")) + errors.Add("Telegram:Speech:ElevenLabs:BaseUrl must be an absolute http(s) URL."); + } + + /// The startup log line (never a key). + public string Describe() + { + var provider = ResolvedTtsProvider; + var stt = IsEnabled ? $"Speech to text: {ResolvedModel}" : "Speech to text: off (no OPENAI_API_KEY)"; + var tts = provider switch + { + TextToSpeechProvider.OpenAi => $"text to speech: {ResolvedTtsModel}, voice {ResolvedTtsVoice}", + TextToSpeechProvider.ElevenLabs => $"text to speech: ElevenLabs {ElevenLabs.ResolvedModel}, voice {ElevenLabs.VoiceId.Trim()}", + _ => "text to speech: off", + }; + if (!IsEnabled && provider == TextToSpeechProvider.None) tts = "text to speech: off (no OPENAI_API_KEY or ELEVENLABS_API_KEY)"; + var line = $"{stt}; {tts}"; + if (provider != TextToSpeechProvider.ElevenLabs && ElevenLabs is { } eleven + && string.IsNullOrWhiteSpace(eleven.ApiKey) != string.IsNullOrWhiteSpace(eleven.VoiceId)) + line += string.IsNullOrWhiteSpace(eleven.VoiceId) + ? " (ElevenLabs off: ELEVENLABS_VOICE_ID is not set)" + : " (ElevenLabs off: ELEVENLABS_API_KEY is not set)"; + return line; + } } diff --git a/hosts/Volcanic.AgenticCore.Telegram/TelegramOptions.cs b/hosts/Volcanic.AgenticCore.Telegram/TelegramOptions.cs index a1e9440..653dc15 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/TelegramOptions.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/TelegramOptions.cs @@ -83,6 +83,10 @@ public sealed class TelegramOptions Speech.TtsModel = ttsModel.Trim(); if (string.IsNullOrWhiteSpace(Speech.TtsVoice) && getEnv("TTS_VOICE") is { } ttsVoice && !string.IsNullOrWhiteSpace(ttsVoice)) Speech.TtsVoice = ttsVoice.Trim(); + if (string.IsNullOrWhiteSpace(Speech.TtsProvider) && getEnv("TTS_PROVIDER") is { } ttsProvider && !string.IsNullOrWhiteSpace(ttsProvider)) + Speech.TtsProvider = ttsProvider.Trim(); + Speech.ElevenLabs ??= new ElevenLabsOptions(); + Speech.ElevenLabs.ApplyEnvironment(getEnv); Triggers ??= new TriggerOptions(); Triggers.ApplyEnvironment(getEnv); if (string.IsNullOrWhiteSpace(DefaultTimeZone) && getEnv("DEFAULT_TIMEZONE") is { } tz && !string.IsNullOrWhiteSpace(tz)) @@ -106,9 +110,7 @@ public sealed class TelegramOptions 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."); + (Speech ?? new SpeechOptions()).Validate(errors); if (!string.IsNullOrWhiteSpace(DefaultTimeZone) && !Chats.ChatTimeZones.TryParse(DefaultTimeZone, out _, out var zoneError)) errors.Add("Telegram:DefaultTimeZone: " + zoneError); (Triggers ?? new TriggerOptions()).Validate(errors); diff --git a/hosts/Volcanic.AgenticCore.Telegram/TelegramProcessEnvironment.cs b/hosts/Volcanic.AgenticCore.Telegram/TelegramProcessEnvironment.cs index ba4b2e3..3ccb743 100644 --- a/hosts/Volcanic.AgenticCore.Telegram/TelegramProcessEnvironment.cs +++ b/hosts/Volcanic.AgenticCore.Telegram/TelegramProcessEnvironment.cs @@ -11,7 +11,7 @@ public static class TelegramProcessEnvironment /// Let through by default: skills (gmail, gdrive) need it. Its value stays in the secret values, so tool output shows [REDACTED]. public const string GoogleClientSecretVariable = "GOOGLE_CLIENT_SECRET"; - /// Adds the section's lists, PROCESS_ENV_ALLOW, GOOGLE_CLIENT_SECRET to the allow-list, and the bot token, the speech and OpenRouter API keys and the Google client secret to the secret values of . + /// Adds the section's lists, PROCESS_ENV_ALLOW, GOOGLE_CLIENT_SECRET to the allow-list, and the bot token, the OpenAI, ElevenLabs and OpenRouter API keys and the Google client secret to the secret values of . public static void Configure(ProcessEnvironmentOptions target, TelegramOptions telegram, ProcessEnvironmentSection section, Func getEnv, GoogleCalendarOptions? calendar = null) { @@ -27,6 +27,7 @@ public static class TelegramProcessEnvironment 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(telegram.Speech?.ElevenLabs?.ApiKey)) target.SecretValues.Add(telegram.Speech.ElevenLabs.ApiKey); if (!string.IsNullOrWhiteSpace(telegram.Triggers?.ApiKey)) target.SecretValues.Add(telegram.Triggers.ApiKey); if (!string.IsNullOrWhiteSpace(calendar?.ClientSecret)) target.SecretValues.Add(calendar.ClientSecret); } diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/ElevenLabsTextToSpeechTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/ElevenLabsTextToSpeechTests.cs new file mode 100644 index 0000000..3e3565e --- /dev/null +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/ElevenLabsTextToSpeechTests.cs @@ -0,0 +1,138 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Volcanic.AgenticCore.Telegram.Speech; + +namespace Volcanic.AgenticCore.Telegram.Tests; + +public sealed class ElevenLabsTextToSpeechTests +{ + private const string Key = "sk_eleven-test-secret-123"; + private const string VoiceId = "21m00Tcm4TlvDq8ikWAM"; + private static readonly byte[] Ogg = [0x4F, 0x67, 0x67, 0x53, 9]; + + private sealed class StubHandler(params (HttpStatusCode Status, byte[] Body)[] answers) : HttpMessageHandler + { + public List<(HttpRequestMessage Request, JsonElement? Body)> Requests { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + JsonElement? body = request.Content is null + ? null + : JsonDocument.Parse(await request.Content.ReadAsStringAsync(cancellationToken)).RootElement.Clone(); + Requests.Add((request, body)); + var (status, bytes) = answers[Math.Min(Requests.Count, answers.Length) - 1]; + return new HttpResponseMessage(status) { Content = new ByteArrayContent(bytes) }; + } + } + + private static (ElevenLabsTextToSpeech Tts, StubHandler Handler) Create(string? model = null, params (HttpStatusCode, byte[])[] answers) + { + var handler = new StubHandler(answers.Length > 0 ? answers : [(HttpStatusCode.OK, Ogg)]); + var options = new SpeechOptions { RetryDelay = TimeSpan.Zero }; + options.ElevenLabs = new ElevenLabsOptions { ApiKey = Key, VoiceId = VoiceId, Model = model ?? "", BaseUrl = "https://el.example/" }; + return (new ElevenLabsTextToSpeech(new HttpClient(handler), options), handler); + } + + private static byte[] Utf8(string text) => Encoding.UTF8.GetBytes(text); + + [Fact] + public async Task Posts_text_and_model_to_the_voice_with_an_opus_format_and_the_key_header() + { + var (tts, handler) = Create(); + Assert.Equal(Ogg, await tts.SynthesizeAsync("Привіт", null, "cheerful", CancellationToken.None)); + + var (request, body) = Assert.Single(handler.Requests); + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal($"https://el.example/v1/text-to-speech/{VoiceId}?output_format=opus_48000_64", request.RequestUri!.ToString()); + Assert.Equal(Key, Assert.Single(request.Headers.GetValues("xi-api-key"))); + Assert.Null(request.Headers.Authorization); + Assert.Equal("Привіт", body!.Value.GetProperty("text").GetString()); // multilingual v2 ignores instructions + Assert.Equal("eleven_multilingual_v2", body.Value.GetProperty("model_id").GetString()); + } + + [Fact] + public async Task V3_turns_instructions_into_audio_tags() + { + var (tts, handler) = Create("eleven_v3"); + await tts.SynthesizeAsync("Привет", null, "warm, calm", CancellationToken.None); + await tts.SynthesizeAsync("Привет", null, "[whispers] [sighs]", CancellationToken.None); + await tts.SynthesizeAsync("Привет", null, null, CancellationToken.None); + + Assert.Equal(["[warm, calm] Привет", "[whispers] [sighs] Привет", "Привет"], + handler.Requests.Select(r => r.Body!.Value.GetProperty("text").GetString())); + Assert.Contains("audio tags", tts.ArgumentGuide, StringComparison.Ordinal); + Assert.Contains("ignored", Create().Tts.ArgumentGuide, StringComparison.Ordinal); + } + + [Fact] + public async Task A_voice_id_argument_is_used_as_is() + { + var (tts, handler) = Create(); + await tts.SynthesizeAsync("hi", "AZnzlk1XvdvUeBnXmlld", null, CancellationToken.None); + Assert.EndsWith("/v1/text-to-speech/AZnzlk1XvdvUeBnXmlld?output_format=opus_48000_64", handler.Requests[0].Request.RequestUri!.ToString()); + } + + [Fact] + public async Task A_voice_name_is_looked_up_once_and_cached() + { + var voices = Utf8("""{"voices":[{"voice_id":"aaaaaaaaaaaaaaaaaaa1","name":"Anna Pro"},{"voice_id":"bbbbbbbbbbbbbbbbbbb2","name":"Anna - calm narrator"}]}"""); + var (tts, handler) = Create(null, (HttpStatusCode.OK, voices), (HttpStatusCode.OK, Ogg)); + await tts.SynthesizeAsync("hi", "anna", null, CancellationToken.None); + await tts.SynthesizeAsync("hi", "Anna", null, CancellationToken.None); + + Assert.Equal(3, handler.Requests.Count); + Assert.Equal(HttpMethod.Get, handler.Requests[0].Request.Method); + Assert.Equal("https://el.example/v2/voices?page_size=100&search=anna", handler.Requests[0].Request.RequestUri!.ToString()); + Assert.All(handler.Requests.Skip(1), r => Assert.Contains("/v1/text-to-speech/bbbbbbbbbbbbbbbbbbb2?", r.Request.RequestUri!.ToString(), StringComparison.Ordinal)); + } + + [Fact] + public async Task An_unknown_voice_name_is_an_error() + { + var (tts, handler) = Create(null, (HttpStatusCode.OK, Utf8("""{"voices":[]}"""))); + var ex = await Assert.ThrowsAsync(() => tts.SynthesizeAsync("hi", "Nobody", null, CancellationToken.None)); + Assert.Contains("no voice named \"Nobody\"", ex.Message, StringComparison.Ordinal); + Assert.Single(handler.Requests); + } + + [Fact] + public async Task Quota_errors_show_status_and_message_with_the_key_hidden_and_are_not_retried() + { + var error = Utf8($$$"""{"detail":{"status":"quota_exceeded","message":"This request exceeds your quota of 10000. Key {{{Key}}}"}}"""); + var (tts, handler) = Create(null, (HttpStatusCode.Unauthorized, error)); + var ex = await Assert.ThrowsAsync(() => tts.SynthesizeAsync("hi", null, null, CancellationToken.None)); + + Assert.Single(handler.Requests); + Assert.StartsWith("ElevenLabs speech synthesis failed: HTTP 401 quota_exceeded: This request exceeds your quota", ex.Message, StringComparison.Ordinal); + Assert.DoesNotContain(Key, ex.Message, StringComparison.Ordinal); + Assert.Contains("[REDACTED]", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task Rate_limits_are_retried() + { + var busy = Utf8("""{"detail":{"status":"too_many_concurrent_requests","message":"busy"}}"""); + var (tts, handler) = Create(null, (HttpStatusCode.TooManyRequests, busy), (HttpStatusCode.OK, Ogg)); + Assert.Equal(Ogg, await tts.SynthesizeAsync("hi", null, null, CancellationToken.None)); + Assert.Equal(2, handler.Requests.Count); + } + + [Fact] + public async Task Rate_limits_that_persist_are_reported() + { + var busy = Utf8("""{"detail":{"status":"too_many_concurrent_requests","message":"busy"}}"""); + var (tts, handler) = Create(null, (HttpStatusCode.TooManyRequests, busy)); + var ex = await Assert.ThrowsAsync(() => tts.SynthesizeAsync("hi", null, null, CancellationToken.None)); + Assert.Equal(SpeechOptions.MaxAttempts, handler.Requests.Count); + Assert.Equal("ElevenLabs speech synthesis failed: HTTP 429 too_many_concurrent_requests: busy", ex.Message); + } + + [Fact] + public async Task Audio_that_is_not_ogg_is_refused() + { + var (tts, _) = Create(null, (HttpStatusCode.OK, Utf8("ID3 mp3 bytes"))); + var ex = await Assert.ThrowsAsync(() => tts.SynthesizeAsync("hi", null, null, CancellationToken.None)); + Assert.Contains("not OGG/Opus", ex.Message, StringComparison.Ordinal); + } +} diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/FormattingAndOptionsTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/FormattingAndOptionsTests.cs index ae333e2..6ac1f59 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/FormattingAndOptionsTests.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/FormattingAndOptionsTests.cs @@ -121,7 +121,7 @@ public sealed class FormattingAndOptionsTests options.ApplyEnvironment(name => name is "OPENAI_API_KEY" or "STT_MODEL" ? "" : null); Assert.False(options.Speech.IsEnabled); Assert.Equal(SpeechOptions.DefaultModel, options.Speech.ResolvedModel); - Assert.Equal("Speech to text and text to speech: off (no OPENAI_API_KEY)", options.Speech.Describe()); + Assert.Equal("Speech to text: off (no OPENAI_API_KEY); text to speech: off (no OPENAI_API_KEY or ELEVENLABS_API_KEY)", options.Speech.Describe()); } [Fact] @@ -143,6 +143,55 @@ public sealed class FormattingAndOptionsTests Assert.Equal(("gpt-4o-mini-tts", "nova"), (new SpeechOptions().ResolvedTtsModel, new SpeechOptions().ResolvedTtsVoice)); } + [Fact] + public void ElevenLabs_reads_its_variables_and_becomes_the_voice_provider() + { + var options = Valid(); + options.ApplyEnvironment(name => name switch + { + "OPENAI_API_KEY" => "sk-env", "ELEVENLABS_API_KEY" => " sk_el ", "ELEVENLABS_VOICE_ID" => "21m00Tcm4TlvDq8ikWAM", + "ELEVENLABS_MODEL" => "eleven_v3", _ => null, + }); + var eleven = options.Speech.ElevenLabs; + Assert.Equal(("sk_el", "21m00Tcm4TlvDq8ikWAM", "eleven_v3"), (eleven.ApiKey, eleven.VoiceId, eleven.ResolvedModel)); + Assert.True(eleven.SupportsAudioTags); + Assert.Equal(TextToSpeechProvider.ElevenLabs, options.Speech.ResolvedTtsProvider); + Assert.Equal("Speech to text: gpt-4o-transcribe; text to speech: ElevenLabs eleven_v3, voice 21m00Tcm4TlvDq8ikWAM", options.Speech.Describe()); + Assert.Equal(("eleven_multilingual_v2", false), (new ElevenLabsOptions().ResolvedModel, new ElevenLabsOptions().SupportsAudioTags)); + } + + [Theory] + [InlineData(null, true, true, TextToSpeechProvider.ElevenLabs)] + [InlineData("elevenlabs", true, true, TextToSpeechProvider.ElevenLabs)] + [InlineData("openai", true, true, TextToSpeechProvider.OpenAi)] + [InlineData("OpenAI", false, true, TextToSpeechProvider.ElevenLabs)] + [InlineData("elevenlabs", true, false, TextToSpeechProvider.OpenAi)] + [InlineData(null, true, false, TextToSpeechProvider.OpenAi)] + [InlineData(null, false, false, TextToSpeechProvider.None)] + public void The_voice_provider_is_the_preferred_one_when_configured_else_the_other(string? provider, bool openAi, bool elevenLabs, TextToSpeechProvider expected) + { + var speech = new SpeechOptions { TtsProvider = provider ?? "", ApiKey = openAi ? "sk" : "" }; + if (elevenLabs) (speech.ElevenLabs.ApiKey, speech.ElevenLabs.VoiceId) = ("el", "voice"); + Assert.Equal(expected, speech.ResolvedTtsProvider); + } + + [Fact] + public void ElevenLabs_without_a_voice_id_stays_off_and_says_why() + { + var options = Valid(); + options.ApplyEnvironment(name => name switch { "OPENAI_API_KEY" => "sk-env", "ELEVENLABS_API_KEY" => "sk_el", "TTS_PROVIDER" => "elevenlabs", _ => null }); + Assert.Equal(TextToSpeechProvider.OpenAi, options.Speech.ResolvedTtsProvider); + Assert.EndsWith("(ElevenLabs off: ELEVENLABS_VOICE_ID is not set)", options.Speech.Describe(), StringComparison.Ordinal); + } + + [Fact] + public void An_unknown_TTS_PROVIDER_stops_the_host() + { + var options = Valid(); + options.ApplyEnvironment(name => name == "TTS_PROVIDER" ? "google" : null); + Assert.Contains("TTS_PROVIDER (Telegram:Speech:TtsProvider) must be 'elevenlabs' or 'openai'.", options.Validate()); + } + [Fact] public void Speech_defaults_to_gpt_4o_transcribe_on_the_openai_endpoint() { diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/ProcessEnvironmentTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/ProcessEnvironmentTests.cs index 637eace..4729c18 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/ProcessEnvironmentTests.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/ProcessEnvironmentTests.cs @@ -65,6 +65,48 @@ public sealed class ProcessEnvironmentTests Assert.Contains("sk-speech-secret", target.SecretValues); } + [Fact] + public void Configure_adds_the_elevenlabs_api_key_to_the_secret_values() + { + var target = new ProcessEnvironmentOptions(); + var telegram = new TelegramOptions { BotToken = Token }; + telegram.Speech.ElevenLabs.ApiKey = "sk_eleven-secret"; + TelegramProcessEnvironment.Configure(target, telegram, new ProcessEnvironmentSection(), _ => null); + Assert.Contains("sk_eleven-secret", target.SecretValues); + } + + [Fact] + public async Task Bash_never_sees_ELEVENLABS_API_KEY_and_its_value_shows_as_REDACTED() + { + const string ElevenKey = "sk_eleven-test-key-0123456789"; + var environment = new Dictionary(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + foreach (var name in new[] { "PATH", "HOME", "SystemRoot", "ComSpec", "TEMP", "TMP" }) + if (Environment.GetEnvironmentVariable(name) is { } value) environment[name] = value; + environment["ELEVENLABS_API_KEY"] = ElevenKey; + await using var host = new ChatHost((h, services) => + { + h.Options.BotToken = Token; + h.Options.Speech.ElevenLabs.ApiKey = ElevenKey; + services.Configure(o => + TelegramProcessEnvironment.Configure(o.ProcessEnvironment, h.Options, new ProcessEnvironmentSection { HardenHostProcess = false }, _ => null)); + services.AddSingleton(sp => + new ProcessSpawnPolicy(sp.GetRequiredService>().Value.ProcessEnvironment, null, () => environment)); + }); + var chat = await host.ChatAsync(); + await File.WriteAllTextAsync(Path.Combine(chat.Session.Cwd, "key.txt"), ElevenKey); + var command = OperatingSystem.IsWindows() ? "set & type key.txt" : "env; cat key.txt"; + host.Faux.Enqueue(FauxTurn.Tool("bash", new { command }), FauxTurn.Say("done")); + + await host.SendAsync("show the environment"); + await host.SettleAsync(); + + var result = LastToolResult(host); + Assert.Contains("PATH=", result, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("ELEVENLABS_API_KEY", result); + Assert.DoesNotContain(ElevenKey, result); + Assert.Contains("[REDACTED]", result); + } + [Fact] public void The_section_binds_from_configuration() { diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/Support/FakeTextToSpeech.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/Support/FakeTextToSpeech.cs index 98d0f9f..5012e29 100644 --- a/tests/Volcanic.AgenticCore.Telegram.Tests/Support/FakeTextToSpeech.cs +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/Support/FakeTextToSpeech.cs @@ -6,6 +6,8 @@ namespace Volcanic.AgenticCore.Telegram.Tests.Support; public sealed class FakeTextToSpeech : ITextToSpeech { public Exception? Failure { get; set; } + public string ArgumentGuide { get; set; } = "voice: fake voices. instructions: fake style."; + public List<(string Text, string? Voice, string? Instructions)> Calls { get; } = []; public Task SynthesizeAsync(string text, string? voice, string? instructions, CancellationToken cancellationToken)