Files
VolcanicAgenticCore/tests/Volcanic.AgenticCore.Telegram.Tests/FormattingAndOptionsTests.cs
EugeneTes 87240238cc feat(telegram): voice tool sends spoken replies as Telegram voice notes
OpenAI gpt-4o-mini-tts (female voice nova by default; TTS_MODEL/TTS_VOICE) renders OGG/Opus,
sent with sendVoice in order with rendered output. Up to 4500 characters per call, split
into 1500-character parts at paragraph/sentence/word boundaries; all parts are synthesized
before any is sent, and API errors come back to the agent as text with the key hidden.
On whenever OPENAI_API_KEY is set.
2026-10-09 10:09:46 +00:00

207 lines
9.1 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 and text to speech: off (no OPENAI_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 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));
}
}