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:
@@ -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
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user