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:
EugeneTes
2026-10-14 13:07:36 +00:00
parent 6e3241fed9
commit 7fef3ce94e
17 changed files with 554 additions and 21 deletions

View File

@@ -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.

View File

@@ -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)

View File

@@ -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 |

View File

@@ -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.`

View File

@@ -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:

View File

@@ -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
builder.Services.AddHttpClient<ITextToSpeech, OpenAiTextToSpeech>(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<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

View File

@@ -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();
}
}

View File

@@ -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();
}

View File

@@ -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>

View File

@@ -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++)

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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()
{

View File

@@ -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()
{

View File

@@ -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)