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

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