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.
This commit is contained in:
@@ -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 `<workspace>/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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.`
|
||||
|
||||
@@ -10,10 +10,10 @@ namespace Volcanic.AgenticCore.Telegram.Chats;
|
||||
/// <summary>Arguments of <c>voice</c>.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>Host extension per chat: the <c>voice</c> tool, which speaks text with OpenAI and sends it as Telegram voice notes.</summary>
|
||||
/// <summary>Host extension per chat: the <c>voice</c> tool, which speaks text with the configured service (ElevenLabs or OpenAI) and sends it as Telegram voice notes.</summary>
|
||||
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:
|
||||
|
||||
@@ -69,10 +69,21 @@ if (options.Speech.IsEnabled) // no key, no ISpeechToText: voice notes get the s
|
||||
builder.Services.AddHttpClient<ISpeechToText, OpenAiSpeechToText>(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
|
||||
// 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<ITextToSpeech, OpenAiTextToSpeech>(client => client.Timeout = TimeSpan.FromMinutes(2))
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) })
|
||||
.SetHandlerLifetime(Timeout.InfiniteTimeSpan);
|
||||
break;
|
||||
case TextToSpeechProvider.ElevenLabs:
|
||||
builder.Services.AddHttpClient<ITextToSpeech, ElevenLabsTextToSpeech>(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<OwnerLoginChannel>();
|
||||
// The extension is constructed per session through ActivatorUtilities, which injects this. LoginUi resolves
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace Volcanic.AgenticCore.Telegram.Speech;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>Telegram:Speech:ElevenLabs</c> section: ElevenLabs text to speech for the <c>voice</c> tool. Configured exactly
|
||||
/// when both <see cref="ApiKey"/> and <see cref="VoiceId"/> are set.
|
||||
/// </summary>
|
||||
public sealed class ElevenLabsOptions
|
||||
{
|
||||
public const string DefaultBaseUrl = "https://api.elevenlabs.io";
|
||||
public const string DefaultModel = "eleven_multilingual_v2";
|
||||
|
||||
/// <summary>OGG/Opus, which Telegram shows as a voice note.</summary>
|
||||
public const string DefaultOutputFormat = "opus_48000_64";
|
||||
|
||||
/// <summary>Falls back to <c>ELEVENLABS_API_KEY</c>.</summary>
|
||||
public string ApiKey { get; set; } = "";
|
||||
|
||||
/// <summary>Falls back to <c>ELEVENLABS_VOICE_ID</c>. A voice id, or the name of a voice in the account's library.</summary>
|
||||
public string VoiceId { get; set; } = "";
|
||||
|
||||
/// <summary>Falls back to <c>ELEVENLABS_MODEL</c>, then <see cref="DefaultModel"/>.</summary>
|
||||
public string Model { get; set; } = "";
|
||||
|
||||
public string BaseUrl { get; set; } = DefaultBaseUrl;
|
||||
|
||||
/// <summary>An <c>opus_48000_*</c> format; others aren't OGG/Opus and are refused.</summary>
|
||||
public string OutputFormat { get; set; } = DefaultOutputFormat;
|
||||
|
||||
public bool IsConfigured => !string.IsNullOrWhiteSpace(ApiKey) && !string.IsNullOrWhiteSpace(VoiceId);
|
||||
|
||||
public string ResolvedModel => string.IsNullOrWhiteSpace(Model) ? DefaultModel : Model.Trim();
|
||||
|
||||
/// <summary>Only v3 models read audio tags such as <c>[whispers]</c>; older models would speak them aloud.</summary>
|
||||
public bool SupportsAudioTags => ResolvedModel.StartsWith("eleven_v3", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Reads the environment shortcuts; the environment fills only empty settings, and an empty variable counts as unset.</summary>
|
||||
public void ApplyEnvironment(Func<string, string?> 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// ElevenLabs' <c>/v1/text-to-speech/{voice_id}</c> with an <c>opus_48000_*</c> 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 (<c>/v2/voices</c>).
|
||||
/// </summary>
|
||||
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<string, string> _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<byte[]> 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<byte[]> 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<string, string> { ["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<string> 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<byte[]> 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();
|
||||
}
|
||||
@@ -8,6 +8,9 @@ public interface ITextToSpeech
|
||||
/// configured defaults when set. Throws <see cref="TextToSpeechException"/> when the service fails.
|
||||
/// </summary>
|
||||
Task<byte[]> SynthesizeAsync(string text, string? voice, string? instructions, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>What the <c>voice</c> and <c>instructions</c> arguments mean for this service, for the tool description.</summary>
|
||||
string ArgumentGuide { get; }
|
||||
}
|
||||
|
||||
/// <summary>Speech synthesis failed; the message is fit for the user and never contains the API key.</summary>
|
||||
|
||||
@@ -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<byte[]> SynthesizeAsync(string text, string? voice, string? instructions, CancellationToken cancellationToken)
|
||||
{
|
||||
for (var attempt = 1; ; attempt++)
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
namespace Volcanic.AgenticCore.Telegram.Speech;
|
||||
|
||||
/// <summary>Which service speaks for the <c>voice</c> tool.</summary>
|
||||
public enum TextToSpeechProvider
|
||||
{
|
||||
None,
|
||||
OpenAi,
|
||||
ElevenLabs,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>Telegram:Speech</c> section. Speech to text (voice notes in) and text to speech (the <c>voice</c> tool) are on
|
||||
/// exactly when <see cref="ApiKey"/> is set.
|
||||
/// The <c>Telegram:Speech</c> section. Speech to text (voice notes in) is on exactly when <see cref="ApiKey"/> is set;
|
||||
/// text to speech (the <c>voice</c> tool) uses <see cref="ElevenLabs"/> when it is configured, else OpenAI with the same key.
|
||||
/// </summary>
|
||||
public sealed class SpeechOptions
|
||||
{
|
||||
@@ -26,6 +34,15 @@ public sealed class SpeechOptions
|
||||
/// <summary>Default delivery instructions (tone, pace); the tool's <c>instructions</c> argument overrides them. Ignored by tts-1.</summary>
|
||||
public string? TtsInstructions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Falls back to <c>TTS_PROVIDER</c>: <c>elevenlabs</c> or <c>openai</c>. Empty prefers ElevenLabs. The preferred
|
||||
/// provider is used when configured, else the other one.
|
||||
/// </summary>
|
||||
public string TtsProvider { get; set; } = "";
|
||||
|
||||
/// <summary>ElevenLabs text to speech (<c>Telegram:Speech:ElevenLabs</c>).</summary>
|
||||
public ElevenLabsOptions ElevenLabs { get; set; } = new();
|
||||
|
||||
/// <summary>Any OpenAI-compatible audio endpoint (transcriptions and speech).</summary>
|
||||
public string BaseUrl { get; set; } = DefaultBaseUrl;
|
||||
|
||||
@@ -46,8 +63,55 @@ public sealed class SpeechOptions
|
||||
|
||||
public string ResolvedTtsVoice => string.IsNullOrWhiteSpace(TtsVoice) ? DefaultTtsVoice : TtsVoice.Trim();
|
||||
|
||||
/// <summary>The startup log line (never the key).</summary>
|
||||
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)";
|
||||
/// <summary>The service behind the <c>voice</c> tool, or <see cref="TextToSpeechProvider.None"/> when neither is configured.</summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Settings problems that must stop the host, added to <paramref name="errors"/>.</summary>
|
||||
public void Validate(List<string> 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.");
|
||||
}
|
||||
|
||||
/// <summary>The startup log line (never a key).</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -11,7 +11,7 @@ public static class TelegramProcessEnvironment
|
||||
/// <summary>Let through by default: skills (gmail, gdrive) need it. Its value stays in the secret values, so tool output shows <c>[REDACTED]</c>.</summary>
|
||||
public const string GoogleClientSecretVariable = "GOOGLE_CLIENT_SECRET";
|
||||
|
||||
/// <summary>Adds the section's lists, <c>PROCESS_ENV_ALLOW</c>, <c>GOOGLE_CLIENT_SECRET</c> to the allow-list, and the bot token, the speech and OpenRouter API keys and the Google client secret to the secret values of <paramref name="target"/>.</summary>
|
||||
/// <summary>Adds the section's lists, <c>PROCESS_ENV_ALLOW</c>, <c>GOOGLE_CLIENT_SECRET</c> 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 <paramref name="target"/>.</summary>
|
||||
public static void Configure(ProcessEnvironmentOptions target, TelegramOptions telegram, ProcessEnvironmentSection section, Func<string, string?> 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);
|
||||
}
|
||||
|
||||
@@ -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<HttpResponseMessage> 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<TextToSpeechException>(() => 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<TextToSpeechException>(() => 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<TextToSpeechException>(() => 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<TextToSpeechException>(() => tts.SynthesizeAsync("hi", null, null, CancellationToken.None));
|
||||
Assert.Contains("not OGG/Opus", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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<string, string?>(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<AgenticCoreOptions>(o =>
|
||||
TelegramProcessEnvironment.Configure(o.ProcessEnvironment, h.Options, new ProcessEnvironmentSection { HardenHostProcess = false }, _ => null));
|
||||
services.AddSingleton<IProcessSpawnPolicy>(sp =>
|
||||
new ProcessSpawnPolicy(sp.GetRequiredService<IOptions<AgenticCoreOptions>>().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()
|
||||
{
|
||||
|
||||
@@ -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<byte[]> SynthesizeAsync(string text, string? voice, string? instructions, CancellationToken cancellationToken)
|
||||
|
||||
Reference in New Issue
Block a user