Files
VolcanicAgenticCore/tests/Volcanic.AgenticCore.Telegram.Tests/FormattingAndOptionsTests.cs
EugeneTes 7fef3ce94e 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.
2026-10-14 13:07:36 +00:00

256 lines
12 KiB
C#

using Microsoft.Extensions.Configuration;
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Speech;
namespace Volcanic.AgenticCore.Telegram.Tests;
public sealed class FormattingAndOptionsTests
{
[Theory]
[InlineData("a < b & c > d", "a &lt; b &amp; c &gt; d")]
[InlineData("**bold** and *it* and _it_", "<b>bold</b> and <i>it</i> and <i>it</i>")]
[InlineData("use `x < 1` here", "use <code>x &lt; 1</code> here")]
[InlineData("`**not bold**`", "<code>**not bold**</code>")]
[InlineData("snake_case_name stays", "snake_case_name stays")]
[InlineData("# Title", "<b>Title</b>")]
[InlineData("- item", "• item")]
[InlineData("[docs](https://example.com/a?b=1&c=2)", "<a href=\"https://example.com/a?b=1&amp;c=2\">docs</a>")]
[InlineData("~~gone~~", "<s>gone</s>")]
[InlineData("🆕 Привет \"quoted\" it's", "🆕 Привет \"quoted\" it's")]
public void Markdown_becomes_telegram_html(string markdown, string html) =>
Assert.Equal(html, TelegramFormatter.ToHtml(markdown));
[Fact]
public void Code_blocks_keep_their_content_literal()
{
var html = TelegramFormatter.ToHtml("Run:\n```bash\necho \"<hi>\" **x**\n```\nDone.");
Assert.Equal("Run:\n<pre><code class=\"language-bash\">echo \"&lt;hi&gt;\" **x**</code></pre>\nDone.", html);
}
[Fact]
public void An_unterminated_code_block_while_streaming_is_still_well_formed()
{
Assert.Equal("<pre>partial &lt;</pre>", TelegramFormatter.ToHtml("```\npartial <"));
}
[Fact]
public void Split_prefers_paragraphs_and_reopens_cut_code_blocks()
{
var code = string.Join("\n", Enumerable.Range(0, 40).Select(i => $"line {i}"));
var text = "intro\n\n```python\n" + code + "\n```\nafter";
var chunks = TelegramFormatter.Split(text, limit: 120);
Assert.True(chunks.Count > 2);
Assert.All(chunks, c => Assert.True(c.Length <= 120, $"{c.Length}: {c}"));
Assert.All(chunks, c => Assert.Equal(0, c.Split('\n').Count(l => l.StartsWith("```", StringComparison.Ordinal)) % 2));
Assert.StartsWith("```python\n", chunks[1]);
Assert.Equal(text.Replace("```python\n", "").Replace("```\n", "").Replace("\n", ""),
string.Concat(chunks.Select(c => string.Join("", c.Split('\n').Where(l => !l.StartsWith("```", StringComparison.Ordinal))))));
}
[Fact]
public void Short_text_is_one_chunk() => Assert.Equal(["hi"], TelegramFormatter.Split("hi"));
[Theory]
[InlineData("/stop", "stop", "")]
[InlineData("/resume@my_bot abc", "resume", "abc")]
[InlineData("/Thinking high ", "thinking", "high")]
public void Commands_parse(string text, string name, string args) => Assert.Equal((name, args), ChatCommands.Parse(text));
[Theory]
[InlineData("hello")]
[InlineData("/")]
public void Non_commands_do_not_parse(string text) => Assert.Null(ChatCommands.Parse(text));
[Fact]
public void An_empty_allow_list_is_rejected()
{
var errors = new TelegramOptions { BotToken = "t" }.Validate();
Assert.Contains(errors, e => e.Contains("allowed users", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Input_debounce_defaults_to_600_ms_and_must_not_be_negative()
{
var options = new TelegramOptions { BotToken = "t", AllowedUserIds = [1] };
Assert.Equal(TimeSpan.FromMilliseconds(600), options.InputDebounce);
Assert.Empty(options.Validate());
options.InputDebounce = TimeSpan.Zero;
Assert.Empty(options.Validate());
options.InputDebounce = TimeSpan.FromMilliseconds(-1);
Assert.Contains(options.Validate(), e => e.Contains("InputDebounce", StringComparison.Ordinal));
}
[Fact]
public void Environment_shortcuts_fill_the_token_and_users()
{
var env = new Dictionary<string, string> { ["TELEGRAM_BOT_TOKEN"] = "123:abc", ["TELEGRAM_ALLOWED_USER_IDS"] = "1, 2;3" };
var options = new TelegramOptions();
options.ApplyEnvironment(k => env.GetValueOrDefault(k));
Assert.Equal("123:abc", options.BotToken);
Assert.Equal([1L, 2L, 3L], options.AllowedUserIds);
Assert.Empty(options.Validate());
Assert.Equal("claude-code/claude-opus-5-5", options.Model);
Assert.True(options.IsAllowed(2));
Assert.False(options.IsAllowed(4));
Assert.False(options.IsAllowed(null));
}
private static TelegramOptions Valid() => new() { BotToken = "t", AllowedUserIds = [1] };
[Fact]
public void Speech_falls_back_to_OPENAI_API_KEY_and_STT_MODEL()
{
var options = Valid();
options.ApplyEnvironment(name => name switch { "OPENAI_API_KEY" => "sk-env", "STT_MODEL" => "whisper-1", _ => null });
Assert.Equal("sk-env", options.Speech.ApiKey);
Assert.Equal("whisper-1", options.Speech.ResolvedModel);
Assert.True(options.Speech.IsEnabled);
Assert.Equal("Speech to text: whisper-1; text to speech: gpt-4o-mini-tts, voice nova", options.Speech.Describe());
}
[Fact]
public void Empty_speech_variables_count_as_unset()
{
var options = Valid();
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: off (no OPENAI_API_KEY); text to speech: off (no OPENAI_API_KEY or ELEVENLABS_API_KEY)", options.Speech.Describe());
}
[Fact]
public void Configured_speech_settings_win_over_the_environment()
{
var options = Valid();
options.Speech.ApiKey = "sk-config";
options.Speech.Model = "gpt-4o-mini-transcribe";
options.ApplyEnvironment(name => name switch { "OPENAI_API_KEY" => "sk-env", "STT_MODEL" => "whisper-1", _ => null });
Assert.Equal(("sk-config", "gpt-4o-mini-transcribe"), (options.Speech.ApiKey, options.Speech.ResolvedModel));
}
[Fact]
public void Text_to_speech_falls_back_to_TTS_MODEL_and_TTS_VOICE()
{
var options = Valid();
options.ApplyEnvironment(name => name switch { "TTS_MODEL" => "tts-1-hd", "TTS_VOICE" => " shimmer ", _ => null });
Assert.Equal(("tts-1-hd", "shimmer"), (options.Speech.ResolvedTtsModel, options.Speech.ResolvedTtsVoice));
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()
{
var speech = new SpeechOptions();
Assert.Equal(("gpt-4o-transcribe", "https://api.openai.com/v1"), (speech.ResolvedModel, speech.BaseUrl));
Assert.False(speech.IsEnabled);
}
[Fact]
public void Inline_image_limit_defaults_to_3_75_MB_and_must_be_positive()
{
Assert.Equal(3_750_000, Valid().InlineImageMaxBytes);
var options = Valid();
options.InlineImageMaxBytes = 0;
Assert.Contains("Telegram:InlineImageMaxBytes must be positive.", options.Validate());
}
[Theory]
[InlineData("ftp://stt.example/v1")]
[InlineData("/v1")]
[InlineData("")]
public void Speech_base_url_must_be_an_absolute_http_url(string url)
{
var options = Valid();
options.Speech.BaseUrl = url;
Assert.Contains("Telegram:Speech:BaseUrl must be an absolute http(s) URL.", options.Validate());
}
[Fact]
public void DefaultTimeZone_comes_from_the_environment_and_unknown_ids_fail_validation()
{
var options = new TelegramOptions { BotToken = "t", AllowedUserIds = [1] };
options.ApplyEnvironment(name => name == "DEFAULT_TIMEZONE" ? " Asia/Tokyo " : null);
Assert.Equal("Asia/Tokyo", options.DefaultTimeZone);
Assert.Equal("Asia/Tokyo", options.ConfiguredTimeZone()!.Id);
Assert.Empty(options.Validate());
var empty = new TelegramOptions { BotToken = "t", AllowedUserIds = [1] };
empty.ApplyEnvironment(name => name == "DEFAULT_TIMEZONE" ? "" : null);
Assert.Null(empty.ConfiguredTimeZone()); // empty = unset: the machine's zone
var bad = new TelegramOptions { BotToken = "t", AllowedUserIds = [1], DefaultTimeZone = "Mars/Olympus" };
Assert.Contains(bad.Validate(), e => e.StartsWith("Telegram:DefaultTimeZone: Unknown time zone \"Mars/Olympus\"", StringComparison.Ordinal));
}
[Fact]
public void Scheduler_options_default_and_validate()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Scheduler:MaxJobsPerTarget"] = "5" }).Build();
var options = Volcanic.AgenticCore.Telegram.Scheduling.SchedulerSetup.Options(config.GetSection("Scheduler"), "/agent");
Assert.True(options.Enabled);
Assert.Equal(Path.Combine("/agent", "scheduler", "jobs.json"), options.LegacyStorePath);
Assert.Equal(TimeSpan.FromSeconds(300), options.DefaultScriptTimeout);
Assert.Equal(5, options.MaxJobsPerTarget);
Assert.Empty(options.Validate());
options.MaxJobsPerTarget = 0;
Assert.Contains(options.Validate(), e => e.Contains("MaxJobsPerTarget", StringComparison.Ordinal));
}
}