forked from admin/VolcanicAgenticCore
174 lines
8.9 KiB
C#
174 lines
8.9 KiB
C#
using Microsoft.Extensions.Time.Testing;
|
|
using Telegram.Bot.Requests;
|
|
using Telegram.Bot.Types.Enums;
|
|
using Volcanic.AgenticCore.Abstractions.Messages;
|
|
using Volcanic.AgenticCore.Telegram.Tests.Support;
|
|
using Volcanic.AgenticCore.Telegram.Triggers;
|
|
using Volcanic.AgenticCore.Testing;
|
|
|
|
namespace Volcanic.AgenticCore.Telegram.Tests;
|
|
|
|
/// <summary>Groups end to end: the bot watches silently, triggers are set up in the private chat, matches reach it.</summary>
|
|
public sealed class GroupChatTests
|
|
{
|
|
private const long Owner = Updates.AllowedUser;
|
|
private const long Group = Updates.GroupId;
|
|
private static readonly DateTimeOffset Now = new(2026, 10, 5, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
private static ChatHost Host(FakeTimeProvider? time = null) =>
|
|
new(time: time ?? new FakeTimeProvider(Now), triggers: new TriggerOptions { ApiKey = "k", Debounce = TimeSpan.FromSeconds(10) });
|
|
|
|
private static Task<Trigger> AddTriggerAsync(ChatHost host, long owner = Owner) =>
|
|
host.Triggers!.Store.AddAsync(owner, new NewTrigger("deploys", Group, "Dev Team", "Is this about deploys?", null, null, 0.7), CancellationToken.None);
|
|
|
|
private static bool SentToGroup(ChatHost host) =>
|
|
host.Bot.Requests.Any(r => r is SendMessageRequest { ChatId.Identifier: Group } or SetMessageReactionRequest { ChatId.Identifier: Group }
|
|
or SendChatActionRequest { ChatId.Identifier: Group });
|
|
|
|
[Fact]
|
|
public async Task A_strangers_matching_message_reaches_the_owners_session_and_nothing_goes_to_the_group()
|
|
{
|
|
var time = new FakeTimeProvider(Now);
|
|
await using var host = Host(time);
|
|
await host.RouteAsync(Updates.BotMembership(ChatMemberStatus.Member)); // an allowed user adds the bot
|
|
await AddTriggerAsync(host);
|
|
host.Faux.Enqueue(FauxTurn.Say("Heads up: Alice says prod deploy is failing."));
|
|
|
|
await host.RouteAsync(Updates.GroupText("prod deploy is failing again", Updates.Stranger));
|
|
await host.RouteAsync(Updates.GroupText("anyone for lunch?", Updates.Stranger));
|
|
await host.Feed!.IdleAsync();
|
|
time.Advance(TimeSpan.FromSeconds(10));
|
|
await host.Engine!.IdleAsync();
|
|
await host.SettleAsync();
|
|
|
|
var prompt = Assert.Single(host.Faux.Requests).Messages.Last().Text;
|
|
Assert.Contains("[Trigger \"deploys\" (t1) matched 1 message in \"Dev Team\"]", prompt, StringComparison.Ordinal);
|
|
Assert.Contains("Alice, score 0.95: prod deploy is failing again", prompt, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("lunch", prompt, StringComparison.Ordinal);
|
|
Assert.Contains(host.Texts, t => t.Contains("Heads up", StringComparison.Ordinal));
|
|
Assert.False(SentToGroup(host));
|
|
Assert.Null(host.Chats.Find(Group)); // a group never gets a session
|
|
Assert.Equal(2, host.TriggerLog.Entries.Count(e => e.Text.StartsWith("Group \"Dev Team\"", StringComparison.Ordinal)));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_group_nobody_allowed_vouched_for_is_ignored_until_an_allowed_user_posts()
|
|
{
|
|
await using var host = Host();
|
|
await AddTriggerAsync(host);
|
|
await host.RouteAsync(Updates.BotMembership(ChatMemberStatus.Member, byUser: Updates.Stranger));
|
|
|
|
await host.RouteAsync(Updates.GroupText("deploy now", Updates.Stranger));
|
|
await host.Feed!.IdleAsync();
|
|
Assert.Null(host.Groups!.Find(Group));
|
|
Assert.Empty(((FakeJev)host.Jev!).Calls);
|
|
|
|
await host.RouteAsync(Updates.GroupText("deploy please", Owner));
|
|
await host.Feed.IdleAsync();
|
|
Assert.True(host.Groups.Find(Group)!.Active);
|
|
Assert.Single(((FakeJev)host.Jev).Calls);
|
|
Assert.False(SentToGroup(host));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Removing_the_bot_pauses_the_group_and_migration_moves_its_triggers()
|
|
{
|
|
await using var host = Host();
|
|
await host.RouteAsync(Updates.BotMembership(ChatMemberStatus.Administrator));
|
|
await AddTriggerAsync(host);
|
|
|
|
await host.RouteAsync(Updates.BotMembership(ChatMemberStatus.Kicked));
|
|
await host.RouteAsync(Updates.GroupText("deploy", Updates.Stranger));
|
|
await host.Feed!.IdleAsync();
|
|
Assert.False(host.Groups!.Find(Group)!.Active);
|
|
Assert.Empty(((FakeJev)host.Jev!).Calls);
|
|
|
|
await host.RouteAsync(Updates.BotMembership(ChatMemberStatus.Member, groupId: -5, title: "Old"));
|
|
await host.Triggers!.Store.AddAsync(Owner, new NewTrigger("old", -5, "Old", "q?", null, null, 0.7), CancellationToken.None);
|
|
var migrate = Updates.GroupText("", Updates.Stranger, groupId: -5, title: "Old", type: ChatType.Group);
|
|
migrate.Message!.Text = null;
|
|
migrate.Message.MigrateToChatId = -1009;
|
|
await host.RouteAsync(migrate);
|
|
|
|
Assert.Equal(-1009, host.Triggers.Store.Find(Owner, "t2")!.GroupId);
|
|
Assert.NotNull(host.Groups.Find(-1009));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Without_triggers_group_messages_are_dropped()
|
|
{
|
|
await using var host = new ChatHost();
|
|
await host.RouteAsync(Updates.GroupText("deploy", Owner));
|
|
Assert.Null(host.Chats.Find(Group));
|
|
Assert.Null(host.Chats.Find(Owner));
|
|
Assert.Empty(host.Bot.Sent);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_agent_creates_lists_and_snoozes_a_trigger_through_the_tool()
|
|
{
|
|
await using var host = Host();
|
|
await host.RouteAsync(Updates.BotMembership(ChatMemberStatus.Member));
|
|
host.Faux.Enqueue(
|
|
FauxTurn.Tool("trigger", new { action = "groups" }),
|
|
FauxTurn.Tool("trigger", new { action = "create", group = "dev", name = "deploys", instructions = "Is this message about deploys?", criteriaTrue = "Deploys, releases, rollbacks", criteriaFalse = "Anything else", threshold = 0.8 }),
|
|
FauxTurn.Tool("trigger", new { action = "snooze", duration = "2h" }),
|
|
FauxTurn.Say("Done."));
|
|
var chat = await host.ChatAsync();
|
|
|
|
await chat.Session.PromptAsync("watch Dev Team for deploys, but keep quiet for two hours");
|
|
|
|
var results = chat.Session.Messages.OfType<ToolResultMessage>().ToList();
|
|
Assert.All(results, r => Assert.False(r.IsError, r.Text));
|
|
Assert.Contains("- Dev Team (id -1001234567890)", results[0].Text, StringComparison.Ordinal);
|
|
Assert.StartsWith("Created: t1 \"deploys\" · Dev Team · threshold 0.80", results[1].Text, StringComparison.Ordinal);
|
|
Assert.StartsWith("Snoozed t1 until", results[2].Text, StringComparison.Ordinal);
|
|
var trigger = host.Triggers!.Store.Find(Owner, "t1")!;
|
|
Assert.Equal(("Deploys, releases, rollbacks", Now.AddHours(2)), (trigger.CriteriaTrue, trigger.SnoozedUntil));
|
|
|
|
await host.SendAsync("/triggers");
|
|
await Wait.UntilAsync(() => host.Bot.Sent.Any(s => s.Text.Contains("snoozed until", StringComparison.Ordinal)), "the /triggers reply");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Only_members_of_a_group_can_put_a_trigger_on_it()
|
|
{
|
|
await using var host = Host();
|
|
await host.RouteAsync(Updates.BotMembership(ChatMemberStatus.Member));
|
|
host.Bot.NonMembers[Owner] = true;
|
|
host.Faux.Enqueue(FauxTurn.Tool("trigger", new { action = "create", group = "Dev Team", name = "x", instructions = "q?" }), FauxTurn.Say("Sorry."));
|
|
var chat = await host.ChatAsync();
|
|
|
|
await chat.Session.PromptAsync("watch Dev Team");
|
|
|
|
var result = chat.Session.Messages.OfType<ToolResultMessage>().Single();
|
|
Assert.True(result.IsError);
|
|
Assert.Contains("isn't a member", result.Text, StringComparison.Ordinal);
|
|
Assert.Empty(host.Triggers!.Store.List(Owner));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Tool_errors_explain_what_is_wrong()
|
|
{
|
|
await using var host = Host();
|
|
await host.RouteAsync(Updates.BotMembership(ChatMemberStatus.Member));
|
|
await host.RouteAsync(Updates.BotMembership(ChatMemberStatus.Member, groupId: -1009, title: "Dev Ops"));
|
|
host.Faux.Enqueue(
|
|
FauxTurn.Tool("trigger", new { action = "create", group = "dev", name = "x", instructions = "q?" }),
|
|
FauxTurn.Tool("trigger", new { action = "create", group = "Dev Team", name = "x", instructions = "q?", threshold = 1.5 }),
|
|
FauxTurn.Tool("trigger", new { action = "snooze", duration = "soon" }),
|
|
FauxTurn.Tool("trigger", new { action = "update", id = "t7", threshold = 0.5 }),
|
|
FauxTurn.Say("Sorry."));
|
|
var chat = await host.ChatAsync();
|
|
|
|
await chat.Session.PromptAsync("try things");
|
|
|
|
var results = chat.Session.Messages.OfType<ToolResultMessage>().ToList();
|
|
Assert.All(results, r => Assert.True(r.IsError));
|
|
Assert.Contains("matches several groups", results[0].Text, StringComparison.Ordinal);
|
|
Assert.Contains("threshold must be between 0 and 1", results[1].Text, StringComparison.Ordinal);
|
|
Assert.Contains("duration looks like", results[2].Text, StringComparison.Ordinal);
|
|
Assert.Contains("No trigger t7", results[3].Text, StringComparison.Ordinal);
|
|
}
|
|
}
|