forked from admin/VolcanicAgenticCore
ChatSettingsStore keeps each chat's IANA zone in <AgentDir>/telegram/chat-settings.json; ChatTimeZoneService is the one path for a change (store, live session, scheduler re-evaluation, reply text). ChatRegistry.TimeZoneFor now reads the store and new sessions start in the chat's zone. /timezone, the set_timezone tool (ChatSettingsExtension), /status zone line, DEFAULT_TIMEZONE. The store updates its cache under the write gate so the last file written is the newest state.
181 lines
7.8 KiB
C#
181 lines
7.8 KiB
C#
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 < b & c > 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 < 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&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 \"<hi>\" **x**</code></pre>\nDone.", html);
|
|
}
|
|
|
|
[Fact]
|
|
public void An_unterminated_code_block_while_streaming_is_still_well_formed()
|
|
{
|
|
Assert.Equal("<pre>partial <</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", 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)", 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 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));
|
|
}
|
|
}
|