forked from admin/VolcanicAgenticCore
feat(telegram): watch groups silently and route Jev topic triggers to the owner's chat
This commit is contained in:
@@ -60,6 +60,9 @@ public sealed class BotSetupService(ITelegramBotClient bot, TelegramOptions opti
|
||||
var menu = GoogleCalendarSetup.MenuFor(ChatCommands.Menu, calendar);
|
||||
await bot.SetMyCommands(menu, cancellationToken: stoppingToken).ConfigureAwait(false);
|
||||
logger.LogInformation("Bot @{Username} ({Id}) is ready", me.Username, me.Id);
|
||||
if (options.Triggers.IsEnabled && !me.CanReadAllGroupMessages)
|
||||
logger.LogWarning("Privacy mode is on: in groups the bot only sees commands, mentions and replies, so group triggers miss most messages. " +
|
||||
"Turn it off with @BotFather (/setprivacy → Disable), then remove and re-add the bot to each group, or make it a group admin.");
|
||||
await SetAdminMenuAsync(menu, stoppingToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -6,11 +6,12 @@ using Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
using Volcanic.AgenticCore.Hosting;
|
||||
using Volcanic.AgenticCore.Telegram.Rendering;
|
||||
using Volcanic.AgenticCore.Telegram.Scheduling;
|
||||
using Volcanic.AgenticCore.Telegram.Triggers;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Chats;
|
||||
|
||||
/// <summary>The bot's own slash commands. Anything else starting with <c>/</c> goes to the session (extension commands).</summary>
|
||||
public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zones = null, JobScheduler? scheduler = null)
|
||||
public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zones = null, JobScheduler? scheduler = null, TriggerServices? triggers = null)
|
||||
{
|
||||
public static readonly IReadOnlyList<BotCommand> Menu =
|
||||
[
|
||||
@@ -23,6 +24,7 @@ public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zon
|
||||
new() { Command = "thinking", Description = "Show or set the thinking level" },
|
||||
new() { Command = "timezone", Description = "Show or set this chat's time zone" },
|
||||
.. SchedulerSetup.Menu,
|
||||
TriggersSetup.MenuCommand,
|
||||
new() { Command = "help", Description = "What this bot does" },
|
||||
];
|
||||
|
||||
@@ -109,6 +111,10 @@ public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zon
|
||||
await ReplyAsync(chat, await TimeZoneAsync(chat, command.Args, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
|
||||
case "triggers":
|
||||
await ReplyAsync(chat, triggers is null ? TriggersSetup.DisabledReply : "**Group triggers**\n" + triggers.Describe(chat.ChatId, chat.Session.TimeZone), cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
|
||||
case "jobs":
|
||||
await ReplyAsync(chat, Jobs(chat), cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
@@ -140,7 +146,7 @@ public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zon
|
||||
/compact – summarise the conversation to free context
|
||||
/thinking [level] – show or set the thinking level
|
||||
/timezone [IANA name|reset] – this chat's time zone
|
||||
""" + SchedulerSetup.HelpLines + GoogleCalendarSetup.HelpLines(chat.Session.Commands);
|
||||
""" + SchedulerSetup.HelpLines + TriggersSetup.HelpLines + GoogleCalendarSetup.HelpLines(chat.Session.Commands);
|
||||
|
||||
private string Status(ChatContext chat)
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@ using Volcanic.AgenticCore.Features.Extensions;
|
||||
using Volcanic.AgenticCore.Hosting;
|
||||
using Volcanic.AgenticCore.Telegram.Inbound;
|
||||
using Volcanic.AgenticCore.Telegram.Rendering;
|
||||
using Volcanic.AgenticCore.Telegram.Triggers;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Chats;
|
||||
|
||||
@@ -78,7 +79,8 @@ public sealed class ChatRegistry(
|
||||
ChatSettingsStore? settings = null,
|
||||
ChatTimeZoneService? zones = null,
|
||||
JobScheduler? scheduler = null,
|
||||
Impersonation? impersonation = null) : IAsyncDisposable
|
||||
Impersonation? impersonation = null,
|
||||
TriggerServices? triggers = null) : IAsyncDisposable
|
||||
{
|
||||
private readonly ConcurrentDictionary<long, Lazy<Task<ChatContext>>> _chats = new();
|
||||
private readonly ILogger _logger = loggerFactory.CreateLogger<ChatRegistry>();
|
||||
@@ -127,6 +129,7 @@ public sealed class ChatRegistry(
|
||||
if (zones is not null) extras.Add(ExtensionRegistration.FromInstance(new ChatSettingsExtension(chatId, zones)));
|
||||
if (scheduler is not null)
|
||||
extras.Add(ExtensionRegistration.FromInstance(new SchedulerExtension(scheduler, Scheduling.TelegramJobDelivery.TargetOf(chatId))));
|
||||
if (triggers is not null) extras.Add(ExtensionRegistration.FromInstance(new TriggerExtension(chatId, triggers, () => TimeZoneFor(chatId))));
|
||||
AgentSession session;
|
||||
try
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Telegram.Bot.Types;
|
||||
using Telegram.Bot.Types.Enums;
|
||||
using Volcanic.AgenticCore.Telegram.Groups;
|
||||
using Volcanic.AgenticCore.Telegram.Inbound;
|
||||
using Volcanic.AgenticCore.Telegram.Polling;
|
||||
using Volcanic.AgenticCore.Telegram.Rendering;
|
||||
@@ -12,7 +13,9 @@ namespace Volcanic.AgenticCore.Telegram.Chats;
|
||||
/// the chat's pending dialog, bot commands run directly, everything else goes through the chat's <see cref="ChatInbox"/>:
|
||||
/// the router takes a lease in update order, composes the message in the background, and the inbox turns the batch into
|
||||
/// a prompt that runs in the background. The admin's <c>/impersonate</c> is handled first; while it is on, the admin's
|
||||
/// updates are routed as the impersonated user's (see <see cref="Impersonation"/>).
|
||||
/// updates are routed as the impersonated user's (see <see cref="Impersonation"/>). Group messages and the bot's own
|
||||
/// membership changes go to the <see cref="GroupFeed"/> before any allow-list check (every member's messages are
|
||||
/// classified); without one (no OpenRouter key) they are dropped. Nothing is ever sent to a group.
|
||||
/// </summary>
|
||||
public sealed class UpdateRouter(
|
||||
TelegramOptions options,
|
||||
@@ -21,7 +24,8 @@ public sealed class UpdateRouter(
|
||||
TelegramSender sender,
|
||||
InboundComposer composer,
|
||||
ILogger<UpdateRouter> logger,
|
||||
Impersonation? impersonation = null) : IUpdateRouter
|
||||
Impersonation? impersonation = null,
|
||||
GroupFeed? groups = null) : IUpdateRouter
|
||||
{
|
||||
/// <summary>The id the bot acts for: the impersonated user's for the admin while impersonating, else <paramref name="id"/>.</summary>
|
||||
private long Effective(long id) => impersonation?.EffectiveId(id) ?? id;
|
||||
@@ -33,9 +37,16 @@ public sealed class UpdateRouter(
|
||||
case { CallbackQuery: { } query }:
|
||||
await OnCallbackAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
case { Message: { } message } when GroupFeed.IsGroup(message.Chat):
|
||||
if (groups is not null) await groups.OnMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
else logger.LogDebug("Ignored a message in group {ChatId}: group triggers are off", message.Chat.Id);
|
||||
break;
|
||||
case { Message: { } message }:
|
||||
await OnMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
case { MyChatMember: { } member }:
|
||||
if (groups is not null) await groups.OnMyChatMemberAsync(member, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
108
hosts/Volcanic.AgenticCore.Telegram/Groups/GroupFeed.cs
Normal file
108
hosts/Volcanic.AgenticCore.Telegram/Groups/GroupFeed.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types;
|
||||
using Telegram.Bot.Types.Enums;
|
||||
using Volcanic.AgenticCore.Telegram.Triggers;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Groups;
|
||||
|
||||
/// <summary>
|
||||
/// The router's entry for groups. A message from an unknown group is dropped unless an allowed user posted it (which
|
||||
/// vouches for the group); a known group's messages go to <see cref="TriggerEngine"/> in the background, at most
|
||||
/// <see cref="TriggerOptions.MaxConcurrency"/> at a time, so polling never waits on Jev. <c>my_chat_member</c>
|
||||
/// updates add the group (when an allowed user added the bot) or pause it (when the bot left). Nothing here ever
|
||||
/// sends anything to a group.
|
||||
/// </summary>
|
||||
public sealed class GroupFeed(
|
||||
GroupRegistry groups,
|
||||
TriggerEngine engine,
|
||||
ITelegramBotClient bot,
|
||||
TelegramOptions options,
|
||||
ILogger<GroupFeed> logger)
|
||||
{
|
||||
private readonly SemaphoreSlim _gate = new(Math.Max(1, options.Triggers.MaxConcurrency));
|
||||
private readonly Lock _lock = new();
|
||||
private readonly List<Task> _inFlight = [];
|
||||
|
||||
public static bool IsGroup(Chat chat) => chat?.Type is ChatType.Group or ChatType.Supergroup;
|
||||
|
||||
public async Task OnMessageAsync(Message message, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
if (message.MigrateToChatId is { } to)
|
||||
{
|
||||
await groups.MigrateAsync(message.Chat.Id, to, cancellationToken).ConfigureAwait(false);
|
||||
await engine.Store.MoveGroupAsync(message.Chat.Id, to, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
if (message.From?.Id == bot.BotId || message.MigrateFromChatId is not null) return;
|
||||
|
||||
var known = groups.Find(message.Chat.Id);
|
||||
if ((known is null || !known.Active) && options.IsAllowed(message.From?.Id))
|
||||
{
|
||||
await groups.VouchAsync(message.Chat, message.From!.Id, cancellationToken).ConfigureAwait(false);
|
||||
known = groups.Find(message.Chat.Id);
|
||||
}
|
||||
if (known is null || !known.Active)
|
||||
{
|
||||
logger.LogInformation("Group \"{GroupTitle}\" ({GroupId}) msg {MessageId} from {Sender}: skipped, {Reason}",
|
||||
GroupMessage.TitleOf(message.Chat), message.Chat.Id, message.Id, GroupMessage.SenderOf(message), known is null ? "unknown group" : "inactive group");
|
||||
return;
|
||||
}
|
||||
await groups.RefreshAsync(message.Chat, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var item = GroupMessage.From(message);
|
||||
var task = Task.Run(async () =>
|
||||
{
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await engine.ProcessAsync(item, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Classifying message {MessageId} of group {GroupId} failed", item.MessageId, item.GroupId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}, CancellationToken.None);
|
||||
lock (_lock)
|
||||
{
|
||||
_inFlight.RemoveAll(t => t.IsCompleted);
|
||||
_inFlight.Add(task);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task OnMyChatMemberAsync(ChatMemberUpdated update, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(update);
|
||||
if (!IsGroup(update.Chat)) return;
|
||||
switch (update.NewChatMember.Status)
|
||||
{
|
||||
case ChatMemberStatus.Member or ChatMemberStatus.Administrator or ChatMemberStatus.Restricted:
|
||||
if (options.IsAllowed(update.From.Id))
|
||||
await groups.VouchAsync(update.Chat, update.From.Id, cancellationToken).ConfigureAwait(false);
|
||||
else if (groups.Find(update.Chat.Id) is null)
|
||||
logger.LogWarning("The bot was added to group {GroupId} \"{Title}\" by user {UserId}, who is not allowed; its messages are ignored",
|
||||
update.Chat.Id, GroupMessage.TitleOf(update.Chat), update.From.Id);
|
||||
break;
|
||||
case ChatMemberStatus.Left or ChatMemberStatus.Kicked:
|
||||
await groups.SetInactiveAsync(update.Chat.Id, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Completes when every message handed to the engine so far has been classified (tests).</summary>
|
||||
public async Task IdleAsync()
|
||||
{
|
||||
Task[] pending;
|
||||
lock (_lock) pending = [.. _inFlight];
|
||||
await Task.WhenAll(pending).ConfigureAwait(false);
|
||||
await engine.IdleAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types;
|
||||
using Telegram.Bot.Types.Enums;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Groups;
|
||||
|
||||
/// <summary>Whether a user is in a group: only members may put a trigger on it.</summary>
|
||||
public interface IGroupMembership
|
||||
{
|
||||
Task<bool> IsMemberAsync(long groupId, long userId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary><c>getChatMember</c>; any failure (the bot was removed, the user is unknown) counts as not a member.</summary>
|
||||
public sealed class TelegramGroupMembership(ITelegramBotClient bot) : IGroupMembership
|
||||
{
|
||||
public async Task<bool> IsMemberAsync(long groupId, long userId, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var member = await bot.GetChatMember(groupId, userId, cancellationToken).ConfigureAwait(false);
|
||||
return member.Status switch
|
||||
{
|
||||
ChatMemberStatus.Creator or ChatMemberStatus.Administrator or ChatMemberStatus.Member => true,
|
||||
ChatMemberStatus.Restricted => member is ChatMemberRestricted { IsMember: true },
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ public sealed class PollingService(
|
||||
IHostApplicationLifetime lifetime,
|
||||
ILogger<PollingService> logger) : BackgroundService, IUpdateHandler
|
||||
{
|
||||
internal static readonly UpdateType[] AllowedUpdates = [UpdateType.Message, UpdateType.CallbackQuery];
|
||||
internal static readonly UpdateType[] AllowedUpdates = [UpdateType.Message, UpdateType.CallbackQuery, UpdateType.MyChatMember];
|
||||
|
||||
private int _offset;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Telegram front end for the agent: long polling, one agent session per private chat, Claude Code as the model.
|
||||
// Telegram front end for the agent: long polling, one agent session per private chat, Claude Code as the model;
|
||||
// groups are watched silently for the owners' topic triggers (Jev through OpenRouter).
|
||||
//
|
||||
// TELEGRAM_BOT_TOKEN=123:abc TELEGRAM_ALLOWED_USER_IDS=111111 dotnet run --project hosts/Volcanic.AgenticCore.Telegram
|
||||
|
||||
@@ -21,6 +22,7 @@ using Volcanic.AgenticCore.Telegram.Polling;
|
||||
using Volcanic.AgenticCore.Telegram.Rendering;
|
||||
using Volcanic.AgenticCore.Telegram.Scheduling;
|
||||
using Volcanic.AgenticCore.Telegram.Speech;
|
||||
using Volcanic.AgenticCore.Telegram.Triggers;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
@@ -96,6 +98,7 @@ builder.Services.AddSingleton<IUpdateRouter, UpdateRouter>();
|
||||
builder.Services.AddSingleton<PollingService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<PollingService>());
|
||||
if (scheduling.Enabled) SchedulerSetup.Register(builder.Services); // the hosted service starts after PollingService
|
||||
if (options.Triggers.IsEnabled) TriggersSetup.Register(builder.Services, options, agentDir); // groups are ignored without a key
|
||||
// Watchers get a short-lived GOOGLE_ACCESS_TOKEN for their chat's calendar, never the client secret or refresh token.
|
||||
if (scheduling.Enabled && calendar is not null) builder.Services.AddSingleton<IScriptEnvironment, CalendarScriptEnvironment>();
|
||||
builder.Services.AddHostedService<BotSetupService>();
|
||||
@@ -108,6 +111,7 @@ HostProcessHardening.Apply(processEnvironment, logger);
|
||||
logger.LogInformation("{ProcessEnvironment}", TelegramProcessEnvironment.Describe(processEnvironment));
|
||||
logger.LogInformation("{Mcp}", mcp.DescribePlaywright());
|
||||
logger.LogInformation("{Speech}", options.Speech.Describe());
|
||||
logger.LogInformation("{Triggers}", options.Triggers.Describe());
|
||||
if (ClaudeInstallation.Locate(Environment.GetEnvironmentVariable) is { } claude)
|
||||
logger.LogInformation("Using Claude Code at {Path}, model {Model}", claude, options.Model);
|
||||
else
|
||||
|
||||
234
hosts/Volcanic.AgenticCore.Telegram/Triggers/TriggerTool.cs
Normal file
234
hosts/Volcanic.AgenticCore.Telegram/Triggers/TriggerTool.cs
Normal file
@@ -0,0 +1,234 @@
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions;
|
||||
using Volcanic.AgenticCore.Abstractions.Tools;
|
||||
using Volcanic.AgenticCore.Telegram.Groups;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Triggers;
|
||||
|
||||
/// <summary>What the trigger tool, the extension and <c>/triggers</c> share; registered only when triggers are on.</summary>
|
||||
public sealed class TriggerServices(TriggerEngine engine, GroupRegistry groups, IGroupMembership membership, TriggerOptions options)
|
||||
{
|
||||
public TriggerEngine Engine => engine;
|
||||
public TriggerStore Store => engine.Store;
|
||||
public GroupRegistry Groups => groups;
|
||||
public IGroupMembership Membership => membership;
|
||||
public TriggerOptions Options => options;
|
||||
|
||||
/// <summary>The chat's triggers as text (<c>list</c> and <c>/triggers</c>).</summary>
|
||||
public string Describe(long owner, TimeZoneInfo zone)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zone);
|
||||
var now = engine.Now;
|
||||
var triggers = Store.List(owner);
|
||||
var body = triggers.Count == 0
|
||||
? "No triggers."
|
||||
: string.Join("\n", triggers.Select(t => TriggerPrompts.Line(t, zone, now, t.SnoozedUntil is null ? 0 : engine.Queued(owner, t.Id))));
|
||||
return body + $"\nNow: {TriggerPrompts.Stamp(now, zone)} ({zone.Id})";
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record TriggerArgs(
|
||||
[property: Description("groups, create, list, update, delete, snooze or unsnooze")] string Action,
|
||||
[property: Description("update, delete, snooze, unsnooze: the trigger id (see list). snooze and unsnooze without id apply to every trigger of this chat")] string? Id = null,
|
||||
[property: Description("create: the group's title or id (see groups)")] string? Group = null,
|
||||
[property: Description("create, update: a short label, e.g. \"deploys\"")] string? Name = null,
|
||||
[property: Description("create, update: the yes/no question asked about ONE group message, e.g. \"Is this message about deployments, releases or production outages?\"")] string? Instructions = null,
|
||||
[property: Description("create, update: what counts as yes, concretely (empty clears)")] string? CriteriaTrue = null,
|
||||
[property: Description("create, update: what counts as no, concretely (empty clears)")] string? CriteriaFalse = null,
|
||||
[property: Description("create, update: the yes-probability (0-1, exclusive) a message needs to match; default 0.7")] double? Threshold = null,
|
||||
[property: Description("update: false pauses the trigger, true resumes it")] bool? Enabled = null,
|
||||
[property: Description("snooze: for how long, e.g. 90m, 2h, 1d, 1h30m")] string? Duration = null,
|
||||
[property: Description("snooze: until this ISO 8601 date-time; without an offset it's the chat's local time")] string? Until = null);
|
||||
|
||||
/// <summary>
|
||||
/// The model's view of this chat's group triggers. Bound to one owner (the private chat); it never sees another
|
||||
/// chat's triggers. Membership is checked against the owner's Telegram user id, which is the private chat's id.
|
||||
/// </summary>
|
||||
public sealed class TriggerTool(long owner, TriggerServices services, Func<TimeZoneInfo> zone) : Tool<TriggerArgs>
|
||||
{
|
||||
public const string ToolName = "trigger";
|
||||
|
||||
public override string Name => ToolName;
|
||||
|
||||
public override string Description =>
|
||||
"Watch Telegram groups the bot is in for topics the user cares about. A trigger is a yes/no question a fast classifier (Jev) asks about every message of one group; " +
|
||||
"when a message scores at or above the threshold you get it as a prompt (with its score) and decide how to tell the user. " +
|
||||
"Actions: groups (watched groups), create, list, update, delete, snooze (collect quietly, then check everything as one batch when the snooze ends), unsnooze.";
|
||||
|
||||
public override string? PromptSnippet => "trigger: watch Telegram groups for topics and get relevant messages as prompts";
|
||||
|
||||
public override IReadOnlyList<string> PromptGuidelines =>
|
||||
[
|
||||
"trigger: write instructions as a yes/no question about ONE message, with concrete criteriaTrue/criteriaFalse (what counts, what doesn't). Restate the trigger in plain words and get the user's confirmation before create.",
|
||||
"trigger: a match arrives as a prompt starting with [Trigger …] and listing each message with its score. Tell the user what matters, briefly, with the link; never post in the group.",
|
||||
"trigger: when the user says a match wasn't relevant: if it scored well above the threshold, tighten the criteria (the question is ambiguous); if it was close to the threshold, raise the threshold. When the user reports a missed message, lower the threshold or broaden the criteria. Always say what you changed.",
|
||||
"trigger: when the user wants quiet (\"not until tonight\", \"only once an hour\"), snooze: messages are collected and checked as one batch when it ends, and you get one prompt if anything is related. For \"once an hour\" snooze again after each batch.",
|
||||
"trigger: the bot only sees group messages if privacy mode is off (@BotFather /setprivacy → Disable) or it is a group admin; an allowed user must add it to the group (or post there once).",
|
||||
];
|
||||
|
||||
public override ToolExecutionMode ExecutionMode => ToolExecutionMode.Sequential;
|
||||
|
||||
protected override async Task<ToolResult> ExecuteAsync(TriggerArgs args, ToolInvocation invocation, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(args);
|
||||
try
|
||||
{
|
||||
return (args.Action ?? "").Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"groups" => ToolResult.Text(Groups()),
|
||||
"create" => await CreateAsync(args, cancellationToken).ConfigureAwait(false),
|
||||
"list" => ToolResult.Text(services.Describe(owner, zone())),
|
||||
"update" => await UpdateAsync(args, cancellationToken).ConfigureAwait(false),
|
||||
"delete" => await DeleteAsync(args, cancellationToken).ConfigureAwait(false),
|
||||
"snooze" => await SnoozeAsync(args, cancellationToken).ConfigureAwait(false),
|
||||
"unsnooze" => await UnsnoozeAsync(args, cancellationToken).ConfigureAwait(false),
|
||||
_ => throw Error("action must be groups, create, list, update, delete, snooze or unsnooze."),
|
||||
};
|
||||
}
|
||||
catch (TriggerLimitException ex)
|
||||
{
|
||||
throw Error(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private string Groups()
|
||||
{
|
||||
var groups = services.Groups.All().Where(g => g.Active).ToList();
|
||||
if (groups.Count == 0)
|
||||
return "The bot isn't watching any group yet. An allowed user has to add it to a group (or post there once while it's a member), " +
|
||||
"and privacy mode must be off (@BotFather /setprivacy → Disable) or the bot must be an admin, so it sees every message.";
|
||||
var mine = services.Store.List(owner);
|
||||
var sb = new StringBuilder("Watched groups:");
|
||||
foreach (var g in groups)
|
||||
{
|
||||
sb.Append(CultureInfo.InvariantCulture, $"\n- {g.Title} (id {g.Id.ToString(CultureInfo.InvariantCulture)})");
|
||||
var ids = mine.Where(t => t.GroupId == g.Id).Select(t => t.Id).ToList();
|
||||
if (ids.Count > 0) sb.Append(" · your triggers: ").Append(string.Join(", ", ids));
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private async Task<ToolResult> CreateAsync(TriggerArgs args, CancellationToken cancellationToken)
|
||||
{
|
||||
var group = ResolveGroup(args.Group);
|
||||
if (string.IsNullOrWhiteSpace(args.Name)) throw Error("create needs name.");
|
||||
if (string.IsNullOrWhiteSpace(args.Instructions)) throw Error("create needs instructions (a yes/no question about one message).");
|
||||
var threshold = Threshold(args.Threshold) ?? services.Options.DefaultThreshold;
|
||||
if (!await services.Membership.IsMemberAsync(group.Id, owner, cancellationToken).ConfigureAwait(false))
|
||||
throw Error($"The user isn't a member of \"{group.Title}\" (or the bot can't check), so no trigger can watch it for them.");
|
||||
var created = await services.Store.AddAsync(owner, new NewTrigger(args.Name.Trim(), group.Id, group.Title, args.Instructions.Trim(),
|
||||
Clean(args.CriteriaTrue), Clean(args.CriteriaFalse), threshold), cancellationToken).ConfigureAwait(false);
|
||||
return ToolResult.Text("Created: " + TriggerPrompts.Line(created, zone(), services.Engine.Now, 0));
|
||||
}
|
||||
|
||||
private async Task<ToolResult> UpdateAsync(TriggerArgs args, CancellationToken cancellationToken)
|
||||
{
|
||||
var id = RequireId(args.Id, "update");
|
||||
var threshold = Threshold(args.Threshold);
|
||||
if (args.Name is not null && args.Name.Trim().Length == 0) throw Error("name can't be empty.");
|
||||
if (args.Instructions is not null && args.Instructions.Trim().Length == 0) throw Error("instructions can't be empty.");
|
||||
var updated = await services.Store.UpdateAsync(owner, id, t => t with
|
||||
{
|
||||
Name = args.Name?.Trim() ?? t.Name,
|
||||
Instructions = args.Instructions?.Trim() ?? t.Instructions,
|
||||
CriteriaTrue = args.CriteriaTrue is null ? t.CriteriaTrue : Clean(args.CriteriaTrue),
|
||||
CriteriaFalse = args.CriteriaFalse is null ? t.CriteriaFalse : Clean(args.CriteriaFalse),
|
||||
Threshold = threshold ?? t.Threshold,
|
||||
Enabled = args.Enabled ?? t.Enabled,
|
||||
}, cancellationToken).ConfigureAwait(false) ?? throw Error($"No trigger {id} in this chat.");
|
||||
return ToolResult.Text("Updated: " + TriggerPrompts.Line(updated, zone(), services.Engine.Now, services.Engine.Queued(owner, updated.Id)));
|
||||
}
|
||||
|
||||
private async Task<ToolResult> DeleteAsync(TriggerArgs args, CancellationToken cancellationToken)
|
||||
{
|
||||
var id = RequireId(args.Id, "delete");
|
||||
var removed = await services.Engine.DeleteAsync(owner, id, cancellationToken).ConfigureAwait(false) ?? throw Error($"No trigger {id} in this chat.");
|
||||
return ToolResult.Text($"Deleted {removed.Id} (\"{removed.Name}\").");
|
||||
}
|
||||
|
||||
private async Task<ToolResult> SnoozeAsync(TriggerArgs args, CancellationToken cancellationToken)
|
||||
{
|
||||
var tz = zone();
|
||||
var now = services.Engine.Now;
|
||||
DateTimeOffset until;
|
||||
if (!string.IsNullOrWhiteSpace(args.Duration) && !string.IsNullOrWhiteSpace(args.Until)) throw Error("snooze takes duration or until, not both.");
|
||||
if (!string.IsNullOrWhiteSpace(args.Duration))
|
||||
until = TriggerPrompts.TryParseDuration(args.Duration, out var duration) ? now + duration : throw Error("duration looks like 90m, 2h, 1d or 1h30m.");
|
||||
else if (!string.IsNullOrWhiteSpace(args.Until))
|
||||
until = TriggerPrompts.TryParseUntil(args.Until, tz, out var at) ? at : throw Error("until must be an ISO 8601 date-time, e.g. 2026-10-05T18:00.");
|
||||
else
|
||||
throw Error("snooze needs duration or until.");
|
||||
if (until <= now) throw Error("That time has already passed.");
|
||||
if (until - now > services.Options.MaxSnooze)
|
||||
throw Error($"A snooze can last at most {services.Options.MaxSnooze.TotalDays.ToString("0.#", CultureInfo.InvariantCulture)} days.");
|
||||
|
||||
var snoozed = await services.Engine.SnoozeAsync(owner, Id(args.Id), until, cancellationToken).ConfigureAwait(false);
|
||||
if (snoozed.Count == 0) throw Error(args.Id is null ? "This chat has no triggers." : $"No trigger {args.Id.Trim()} in this chat.");
|
||||
return ToolResult.Text($"Snoozed {string.Join(", ", snoozed.Select(t => t.Id))} until {TriggerPrompts.Stamp(until, tz)} ({tz.Id}). " +
|
||||
"Messages are collected meanwhile and checked as one batch when the snooze ends; you get one prompt if anything is related.");
|
||||
}
|
||||
|
||||
private async Task<ToolResult> UnsnoozeAsync(TriggerArgs args, CancellationToken cancellationToken)
|
||||
{
|
||||
var id = Id(args.Id);
|
||||
if (id is not null && services.Store.Find(owner, id) is null) throw Error($"No trigger {id} in this chat.");
|
||||
var results = await services.Engine.UnsnoozeAsync(owner, id, cancellationToken).ConfigureAwait(false);
|
||||
if (results.Count == 0) return ToolResult.Text("Nothing is snoozed.");
|
||||
var lines = results.Select(r => r switch
|
||||
{
|
||||
{ Error: { } error } => $"{r.Trigger.Id}: Jev failed ({error}); the {Count(r.Count)} were lost.",
|
||||
{ Count: 0 } => $"{r.Trigger.Id}: nothing was queued.",
|
||||
{ Matched: true } => $"{r.Trigger.Id}: {Count(r.Count)}, related (score {r.Score!.Value.ToString("0.00", CultureInfo.InvariantCulture)}); they follow as a separate prompt.",
|
||||
{ Score: { } score } => $"{r.Trigger.Id}: {Count(r.Count)}, nothing related (best score {score.ToString("0.00", CultureInfo.InvariantCulture)}).",
|
||||
_ => $"{r.Trigger.Id}: {Count(r.Count)} dropped (the trigger is off).",
|
||||
});
|
||||
return ToolResult.Text("Unsnoozed.\n" + string.Join("\n", lines));
|
||||
}
|
||||
|
||||
private KnownGroup ResolveGroup(string? text)
|
||||
{
|
||||
var key = text?.Trim() ?? "";
|
||||
if (key.Length == 0) throw Error("create needs group (see action groups).");
|
||||
var groups = services.Groups.All().Where(g => g.Active).ToList();
|
||||
if (long.TryParse(key, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var id))
|
||||
return groups.Find(g => g.Id == id) ?? throw Error($"The bot isn't watching a group with id {key} (see action groups).");
|
||||
var exact = groups.Where(g => string.Equals(g.Title, key, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
var matches = exact.Count > 0 ? exact : groups.Where(g => g.Title.Contains(key, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
return matches.Count switch
|
||||
{
|
||||
1 => matches[0],
|
||||
0 => throw Error($"No watched group matches \"{key}\" (see action groups)."),
|
||||
_ => throw Error($"\"{key}\" matches several groups: {string.Join(", ", matches.Select(g => $"{g.Title} ({g.Id.ToString(CultureInfo.InvariantCulture)})"))}. Use the id."),
|
||||
};
|
||||
}
|
||||
|
||||
private static double? Threshold(double? value) =>
|
||||
value is { } v && (v <= 0 || v >= 1 || double.IsNaN(v)) ? throw Error("threshold must be between 0 and 1 (exclusive).") : value;
|
||||
|
||||
private static string RequireId(string? id, string action) => Id(id) ?? throw Error($"{action} needs id (see action list).");
|
||||
|
||||
private static string? Id(string? id) => string.IsNullOrWhiteSpace(id) ? null : id.Trim().ToLowerInvariant();
|
||||
|
||||
private static string? Clean(string? text) => string.IsNullOrWhiteSpace(text) ? null : text.Trim();
|
||||
|
||||
private static string Count(int n) => n.ToString(CultureInfo.InvariantCulture) + (n == 1 ? " message" : " messages");
|
||||
|
||||
private static ToolExecutionException Error(string message) => new(ToolResult.Text(message));
|
||||
}
|
||||
|
||||
/// <summary>Host extension per private chat: the <c>trigger</c> tool.</summary>
|
||||
public sealed class TriggerExtension(long chatId, TriggerServices services, Func<TimeZoneInfo> zone) : IExtension
|
||||
{
|
||||
public const string ExtensionName = "telegram-triggers";
|
||||
|
||||
public string Name => ExtensionName;
|
||||
|
||||
public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(api);
|
||||
api.RegisterTool(new TriggerTool(chatId, services, zone));
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Telegram.Bot.Types;
|
||||
using Volcanic.AgenticCore.Telegram.Chats;
|
||||
using Volcanic.AgenticCore.Telegram.Groups;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Triggers;
|
||||
|
||||
/// <summary>Everything the Telegram host does for group triggers, kept out of the shared host files.</summary>
|
||||
public static class TriggersSetup
|
||||
{
|
||||
public const string DisabledReply = "Group triggers are off on this bot (no OPENROUTER_API_KEY).";
|
||||
|
||||
public const string HelpLines = "\n/triggers – this chat's group triggers (ask me to create, change or snooze them)";
|
||||
|
||||
public static readonly BotCommand MenuCommand = new() { Command = "triggers", Description = "Group triggers watching for topics" };
|
||||
|
||||
/// <summary>The Jev client, groups, store, engine, delivery, feed and the hosted service. Only when triggers are on.</summary>
|
||||
public static void Register(IServiceCollection services, TelegramOptions options, string agentDir)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
var triggers = options.Triggers;
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.AddSingleton(triggers);
|
||||
// The engine (a singleton) keeps this typed client for the process lifetime: let the handler recycle its pooled
|
||||
// connections instead, so DNS changes are still picked up.
|
||||
services.AddHttpClient<IJevClient, JevClient>(client => client.Timeout = TimeSpan.FromSeconds(30))
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) })
|
||||
.SetHandlerLifetime(Timeout.InfiniteTimeSpan);
|
||||
services.AddSingleton(sp => new GroupRegistry(GroupRegistry.DefaultPath(agentDir), sp.GetRequiredService<ILogger<GroupRegistry>>(), sp.GetRequiredService<TimeProvider>()));
|
||||
services.AddSingleton(sp => new TriggerStore(options.WorkspaceRoot, triggers.MaxTriggersPerChat, sp.GetRequiredService<ILogger<TriggerStore>>(), sp.GetRequiredService<TimeProvider>()));
|
||||
services.AddSingleton(new SnoozeQueue(triggers.MaxQueuedPerTrigger));
|
||||
// Resolves the registry at call time: ChatRegistry → trigger extension → engine → delivery would be a cycle.
|
||||
services.AddSingleton<ITriggerDelivery>(sp => new TelegramTriggerDelivery(
|
||||
() => sp.GetRequiredService<ChatRegistry>(), sp.GetRequiredService<TimeProvider>(), sp.GetRequiredService<ILogger<TelegramTriggerDelivery>>()));
|
||||
services.AddSingleton<TriggerDispatcher>();
|
||||
services.AddSingleton<TriggerEngine>();
|
||||
services.AddSingleton<IGroupMembership, TelegramGroupMembership>();
|
||||
services.AddSingleton<TriggerServices>();
|
||||
services.AddSingleton<GroupFeed>();
|
||||
services.AddHostedService<TriggersHostedService>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Flushes snoozes that ended while the host was down and arms the snooze timer.</summary>
|
||||
public sealed class TriggersHostedService(TriggerEngine engine) : IHostedService
|
||||
{
|
||||
public Task StartAsync(CancellationToken cancellationToken) => engine.StartAsync(cancellationToken);
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
engine.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
173
tests/Volcanic.AgenticCore.Telegram.Tests/GroupChatTests.cs
Normal file
173
tests/Volcanic.AgenticCore.Telegram.Tests/GroupChatTests.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ using Volcanic.AgenticCore.Telegram.Chats;
|
||||
using Volcanic.AgenticCore.Telegram.Inbound;
|
||||
using Volcanic.AgenticCore.Telegram.Rendering;
|
||||
using Volcanic.AgenticCore.Telegram.Scheduling;
|
||||
using Volcanic.AgenticCore.Telegram.Groups;
|
||||
using Volcanic.AgenticCore.Telegram.Triggers;
|
||||
using Volcanic.AgenticCore.Testing;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Tests.Support;
|
||||
@@ -53,7 +55,7 @@ public sealed class ChatHost : IAsyncDisposable
|
||||
public ChatHost(Action<ChatHost, IServiceCollection>? configure = null, Action<AgenticCoreOptions>? core = null, TimeProvider? time = null,
|
||||
Volcanic.AgenticCore.Extensions.GoogleCalendar.GoogleCalendarOptions? calendar = null,
|
||||
Volcanic.AgenticCore.Telegram.Speech.ISpeechToText? speech = null, string? root = null, SchedulerOptions? scheduler = null,
|
||||
IScriptRunner? runner = null)
|
||||
IScriptRunner? runner = null, TriggerOptions? triggers = null, IJevClient? jev = null)
|
||||
{
|
||||
_ownsRoot = root is null;
|
||||
_root = root ?? Path.Combine(Path.GetTempPath(), "volcanic-telegram", Guid.NewGuid().ToString("N"));
|
||||
@@ -68,6 +70,7 @@ public sealed class ChatHost : IAsyncDisposable
|
||||
TypingInterval = TimeSpan.FromMilliseconds(50),
|
||||
InputDebounce = TimeSpan.FromMilliseconds(20),
|
||||
};
|
||||
if (triggers is not null) Options.Triggers = triggers;
|
||||
var store = new InMemorySessionStore();
|
||||
var services = new ServiceCollection();
|
||||
if (time is not null) services.AddSingleton(time); // sessions render the prompt date from it
|
||||
@@ -97,13 +100,36 @@ public sealed class ChatHost : IAsyncDisposable
|
||||
}
|
||||
Zones = new ChatTimeZoneService(Settings, id => Chats!.Find(id)?.Session, clock, Scheduler);
|
||||
Impersonation = new Impersonation(Options);
|
||||
Chats = new ChatRegistry(_services.GetRequiredService<IAgentSessionFactory>(), sender, Options, loggers, time, calendar, Settings, Zones, Scheduler, Impersonation);
|
||||
if (triggers is not null)
|
||||
{
|
||||
Jev = jev ?? new FakeJev();
|
||||
Groups = new GroupRegistry(GroupRegistry.DefaultPath(agentDir), NullLogger<GroupRegistry>.Instance, clock);
|
||||
var triggerStore = new TriggerStore(Options.WorkspaceRoot, triggers.MaxTriggersPerChat, NullLogger<TriggerStore>.Instance, clock);
|
||||
var delivery = new TelegramTriggerDelivery(() => Chats!, clock, NullLogger<TelegramTriggerDelivery>.Instance);
|
||||
_dispatcher = new TriggerDispatcher(delivery, triggers, clock, NullLogger<TriggerDispatcher>.Instance);
|
||||
Engine = new TriggerEngine(triggerStore, new SnoozeQueue(triggers.MaxQueuedPerTrigger), Jev, _dispatcher, delivery, clock, TriggerLog);
|
||||
Triggers = new TriggerServices(Engine, Groups, new TelegramGroupMembership(Bot), triggers);
|
||||
Feed = new GroupFeed(Groups, Engine, Bot, Options, NullLogger<GroupFeed>.Instance);
|
||||
}
|
||||
Chats = new ChatRegistry(_services.GetRequiredService<IAgentSessionFactory>(), sender, Options, loggers, time, calendar, Settings, Zones, Scheduler, Impersonation, Triggers);
|
||||
if (Scheduler is not null) Delivery = new TelegramJobDelivery(Chats, Settings, Options, clock, NullLogger<TelegramJobDelivery>.Instance);
|
||||
var media = new InboundMedia(sender, Options, time ?? TimeProvider.System, NullLogger<InboundMedia>.Instance, speech);
|
||||
Composer = new InboundComposer(sender, media, NullLogger<InboundComposer>.Instance);
|
||||
Router = new UpdateRouter(Options, Chats, new ChatCommands(sender, Zones, Scheduler), sender, Composer, NullLogger<UpdateRouter>.Instance, Impersonation);
|
||||
Router = new UpdateRouter(Options, Chats, new ChatCommands(sender, Zones, Scheduler, Triggers), sender, Composer, NullLogger<UpdateRouter>.Instance, Impersonation, Feed);
|
||||
}
|
||||
|
||||
private readonly TriggerDispatcher? _dispatcher;
|
||||
|
||||
/// <summary>Set when the host was built with <c>triggers:</c> options.</summary>
|
||||
public TriggerServices? Triggers { get; }
|
||||
public TriggerEngine? Engine { get; }
|
||||
public GroupRegistry? Groups { get; }
|
||||
public GroupFeed? Feed { get; }
|
||||
public IJevClient? Jev { get; }
|
||||
|
||||
/// <summary>The engine's log lines (one per group message).</summary>
|
||||
public ListLogger<TriggerEngine> TriggerLog { get; } = new();
|
||||
|
||||
/// <summary>Off until <see cref="TelegramOptions.AdminUserId"/> is set on <see cref="Options"/>.</summary>
|
||||
public Impersonation Impersonation { get; }
|
||||
|
||||
@@ -140,6 +166,9 @@ public sealed class ChatHost : IAsyncDisposable
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Scheduler is not null) await Scheduler.StopAsync(CancellationToken.None);
|
||||
if (Feed is not null) await Feed.IdleAsync();
|
||||
Engine?.Dispose();
|
||||
_dispatcher?.Dispose();
|
||||
await Chats.DisposeAsync();
|
||||
await _services.DisposeAsync();
|
||||
if (_ownsRoot)
|
||||
|
||||
@@ -34,6 +34,9 @@ public sealed class FakeBotClient : ITelegramBotClient
|
||||
/// <summary>When set, a download of a scripted file writes one byte and then fails with a network error.</summary>
|
||||
public bool FailDownloads { get; set; }
|
||||
|
||||
/// <summary>Users <c>getChatMember</c> reports as having left (everyone else is a member).</summary>
|
||||
public ConcurrentDictionary<long, bool> NonMembers { get; } = new();
|
||||
|
||||
/// <summary>Called for every non-poll request before it is answered; throw to simulate a failure.</summary>
|
||||
public Action<IRequest>? OnSend { get; set; }
|
||||
|
||||
@@ -127,6 +130,10 @@ public sealed class FakeBotClient : ITelegramBotClient
|
||||
case GetFileRequest getFile:
|
||||
result = new TGFile { FileId = getFile.FileId, FileUniqueId = getFile.FileId + "-u", FilePath = $"photos/{getFile.FileId}.jpg", FileSize = 3 };
|
||||
break;
|
||||
case GetChatMemberRequest member:
|
||||
var user = new User { Id = member.UserId, FirstName = "Member" };
|
||||
result = NonMembers.ContainsKey(member.UserId) ? new ChatMemberLeft { User = user } : new ChatMemberMember { User = user };
|
||||
break;
|
||||
case SendPhotoRequest photo:
|
||||
result = RecordUpload("sendPhoto", photo.ChatId, photo.Photo, photo.Caption, photo.ParseMode);
|
||||
break;
|
||||
|
||||
@@ -39,6 +39,43 @@ public static class Updates
|
||||
},
|
||||
};
|
||||
|
||||
public const long GroupId = -1001234567890;
|
||||
|
||||
/// <summary>A text message in a group (a supergroup unless <paramref name="type"/> says otherwise).</summary>
|
||||
public static Update GroupText(string text, long userId = Stranger, long groupId = GroupId, string title = "Dev Team", ChatType type = ChatType.Supergroup,
|
||||
string firstName = "Alice") => new()
|
||||
{
|
||||
Id = Interlocked.Increment(ref _nextUpdateId),
|
||||
Message = new Message
|
||||
{
|
||||
Id = Interlocked.Increment(ref _nextMessageId),
|
||||
From = new User { Id = userId, FirstName = firstName },
|
||||
Chat = new Chat { Id = groupId, Type = type, Title = title },
|
||||
Text = text,
|
||||
Date = DateTime.UtcNow,
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>The bot's own membership in a group changed (added by <paramref name="byUser"/>, or removed).</summary>
|
||||
public static Update BotMembership(ChatMemberStatus status, long byUser = AllowedUser, long groupId = GroupId, string title = "Dev Team") => new()
|
||||
{
|
||||
Id = Interlocked.Increment(ref _nextUpdateId),
|
||||
MyChatMember = new ChatMemberUpdated
|
||||
{
|
||||
Chat = new Chat { Id = groupId, Type = ChatType.Supergroup, Title = title },
|
||||
From = new User { Id = byUser, FirstName = "Adder" },
|
||||
Date = DateTime.UtcNow,
|
||||
OldChatMember = new ChatMemberLeft { User = new User { Id = BotUserId, IsBot = true, FirstName = "Fake" } },
|
||||
NewChatMember = status switch
|
||||
{
|
||||
ChatMemberStatus.Administrator => new ChatMemberAdministrator { User = new User { Id = BotUserId, IsBot = true, FirstName = "Fake" } },
|
||||
ChatMemberStatus.Kicked => new ChatMemberBanned { User = new User { Id = BotUserId, IsBot = true, FirstName = "Fake" } },
|
||||
ChatMemberStatus.Left => new ChatMemberLeft { User = new User { Id = BotUserId, IsBot = true, FirstName = "Fake" } },
|
||||
_ => new ChatMemberMember { User = new User { Id = BotUserId, IsBot = true, FirstName = "Fake" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
public static Update WithId(int id) => new() { Id = id, Message = Text("#" + id).Message };
|
||||
|
||||
/// <summary>The fake bot's user id (<see cref="FakeBotClient.BotId"/>).</summary>
|
||||
|
||||
Reference in New Issue
Block a user