feat(telegram): watch groups silently and route Jev topic triggers to the owner's chat

This commit is contained in:
EugeneTes
2026-10-05 16:26:16 +00:00
parent 4ce71201b0
commit be3c77b4d0
14 changed files with 716 additions and 10 deletions

View File

@@ -60,6 +60,9 @@ public sealed class BotSetupService(ITelegramBotClient bot, TelegramOptions opti
var menu = GoogleCalendarSetup.MenuFor(ChatCommands.Menu, calendar); var menu = GoogleCalendarSetup.MenuFor(ChatCommands.Menu, calendar);
await bot.SetMyCommands(menu, cancellationToken: stoppingToken).ConfigureAwait(false); await bot.SetMyCommands(menu, cancellationToken: stoppingToken).ConfigureAwait(false);
logger.LogInformation("Bot @{Username} ({Id}) is ready", me.Username, me.Id); 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); await SetAdminMenuAsync(menu, stoppingToken).ConfigureAwait(false);
return true; return true;
} }

View File

@@ -6,11 +6,12 @@ using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Hosting; using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Telegram.Rendering; using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Scheduling; using Volcanic.AgenticCore.Telegram.Scheduling;
using Volcanic.AgenticCore.Telegram.Triggers;
namespace Volcanic.AgenticCore.Telegram.Chats; 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> /// <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 = 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 = "thinking", Description = "Show or set the thinking level" },
new() { Command = "timezone", Description = "Show or set this chat's time zone" }, new() { Command = "timezone", Description = "Show or set this chat's time zone" },
.. SchedulerSetup.Menu, .. SchedulerSetup.Menu,
TriggersSetup.MenuCommand,
new() { Command = "help", Description = "What this bot does" }, 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); await ReplyAsync(chat, await TimeZoneAsync(chat, command.Args, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
return true; 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": case "jobs":
await ReplyAsync(chat, Jobs(chat), cancellationToken).ConfigureAwait(false); await ReplyAsync(chat, Jobs(chat), cancellationToken).ConfigureAwait(false);
return true; return true;
@@ -140,7 +146,7 @@ public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zon
/compact – summarise the conversation to free context /compact – summarise the conversation to free context
/thinking [level] – show or set the thinking level /thinking [level] – show or set the thinking level
/timezone [IANA name|reset] – this chat's time zone /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) private string Status(ChatContext chat)
{ {

View File

@@ -11,6 +11,7 @@ using Volcanic.AgenticCore.Features.Extensions;
using Volcanic.AgenticCore.Hosting; using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Telegram.Inbound; using Volcanic.AgenticCore.Telegram.Inbound;
using Volcanic.AgenticCore.Telegram.Rendering; using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Triggers;
namespace Volcanic.AgenticCore.Telegram.Chats; namespace Volcanic.AgenticCore.Telegram.Chats;
@@ -78,7 +79,8 @@ public sealed class ChatRegistry(
ChatSettingsStore? settings = null, ChatSettingsStore? settings = null,
ChatTimeZoneService? zones = null, ChatTimeZoneService? zones = null,
JobScheduler? scheduler = 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 ConcurrentDictionary<long, Lazy<Task<ChatContext>>> _chats = new();
private readonly ILogger _logger = loggerFactory.CreateLogger<ChatRegistry>(); 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 (zones is not null) extras.Add(ExtensionRegistration.FromInstance(new ChatSettingsExtension(chatId, zones)));
if (scheduler is not null) if (scheduler is not null)
extras.Add(ExtensionRegistration.FromInstance(new SchedulerExtension(scheduler, Scheduling.TelegramJobDelivery.TargetOf(chatId)))); 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; AgentSession session;
try try
{ {

View File

@@ -1,6 +1,7 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Telegram.Bot.Types; using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums; using Telegram.Bot.Types.Enums;
using Volcanic.AgenticCore.Telegram.Groups;
using Volcanic.AgenticCore.Telegram.Inbound; using Volcanic.AgenticCore.Telegram.Inbound;
using Volcanic.AgenticCore.Telegram.Polling; using Volcanic.AgenticCore.Telegram.Polling;
using Volcanic.AgenticCore.Telegram.Rendering; 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 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 /// 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 /// 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> /// </summary>
public sealed class UpdateRouter( public sealed class UpdateRouter(
TelegramOptions options, TelegramOptions options,
@@ -21,7 +24,8 @@ public sealed class UpdateRouter(
TelegramSender sender, TelegramSender sender,
InboundComposer composer, InboundComposer composer,
ILogger<UpdateRouter> logger, 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> /// <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; private long Effective(long id) => impersonation?.EffectiveId(id) ?? id;
@@ -33,9 +37,16 @@ public sealed class UpdateRouter(
case { CallbackQuery: { } query }: case { CallbackQuery: { } query }:
await OnCallbackAsync(query, cancellationToken).ConfigureAwait(false); await OnCallbackAsync(query, cancellationToken).ConfigureAwait(false);
break; 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 }: case { Message: { } message }:
await OnMessageAsync(message, cancellationToken).ConfigureAwait(false); await OnMessageAsync(message, cancellationToken).ConfigureAwait(false);
break; break;
case { MyChatMember: { } member }:
if (groups is not null) await groups.OnMyChatMemberAsync(member, cancellationToken).ConfigureAwait(false);
break;
} }
} }

View 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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -33,7 +33,7 @@ public sealed class PollingService(
IHostApplicationLifetime lifetime, IHostApplicationLifetime lifetime,
ILogger<PollingService> logger) : BackgroundService, IUpdateHandler 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; private int _offset;

View File

@@ -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 // 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.Rendering;
using Volcanic.AgenticCore.Telegram.Scheduling; using Volcanic.AgenticCore.Telegram.Scheduling;
using Volcanic.AgenticCore.Telegram.Speech; using Volcanic.AgenticCore.Telegram.Speech;
using Volcanic.AgenticCore.Telegram.Triggers;
var builder = Host.CreateApplicationBuilder(args); var builder = Host.CreateApplicationBuilder(args);
@@ -96,6 +98,7 @@ builder.Services.AddSingleton<IUpdateRouter, UpdateRouter>();
builder.Services.AddSingleton<PollingService>(); builder.Services.AddSingleton<PollingService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<PollingService>()); builder.Services.AddHostedService(sp => sp.GetRequiredService<PollingService>());
if (scheduling.Enabled) SchedulerSetup.Register(builder.Services); // the hosted service starts after 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. // 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>(); if (scheduling.Enabled && calendar is not null) builder.Services.AddSingleton<IScriptEnvironment, CalendarScriptEnvironment>();
builder.Services.AddHostedService<BotSetupService>(); builder.Services.AddHostedService<BotSetupService>();
@@ -108,6 +111,7 @@ HostProcessHardening.Apply(processEnvironment, logger);
logger.LogInformation("{ProcessEnvironment}", TelegramProcessEnvironment.Describe(processEnvironment)); logger.LogInformation("{ProcessEnvironment}", TelegramProcessEnvironment.Describe(processEnvironment));
logger.LogInformation("{Mcp}", mcp.DescribePlaywright()); logger.LogInformation("{Mcp}", mcp.DescribePlaywright());
logger.LogInformation("{Speech}", options.Speech.Describe()); logger.LogInformation("{Speech}", options.Speech.Describe());
logger.LogInformation("{Triggers}", options.Triggers.Describe());
if (ClaudeInstallation.Locate(Environment.GetEnvironmentVariable) is { } claude) if (ClaudeInstallation.Locate(Environment.GetEnvironmentVariable) is { } claude)
logger.LogInformation("Using Claude Code at {Path}, model {Model}", claude, options.Model); logger.LogInformation("Using Claude Code at {Path}, model {Model}", claude, options.Model);
else else

View 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;
}
}

View File

@@ -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;
}
}

View 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);
}
}

View File

@@ -12,6 +12,8 @@ using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Inbound; using Volcanic.AgenticCore.Telegram.Inbound;
using Volcanic.AgenticCore.Telegram.Rendering; using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Scheduling; using Volcanic.AgenticCore.Telegram.Scheduling;
using Volcanic.AgenticCore.Telegram.Groups;
using Volcanic.AgenticCore.Telegram.Triggers;
using Volcanic.AgenticCore.Testing; using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Telegram.Tests.Support; 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, public ChatHost(Action<ChatHost, IServiceCollection>? configure = null, Action<AgenticCoreOptions>? core = null, TimeProvider? time = null,
Volcanic.AgenticCore.Extensions.GoogleCalendar.GoogleCalendarOptions? calendar = null, Volcanic.AgenticCore.Extensions.GoogleCalendar.GoogleCalendarOptions? calendar = null,
Volcanic.AgenticCore.Telegram.Speech.ISpeechToText? speech = null, string? root = null, SchedulerOptions? scheduler = 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; _ownsRoot = root is null;
_root = root ?? Path.Combine(Path.GetTempPath(), "volcanic-telegram", Guid.NewGuid().ToString("N")); _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), TypingInterval = TimeSpan.FromMilliseconds(50),
InputDebounce = TimeSpan.FromMilliseconds(20), InputDebounce = TimeSpan.FromMilliseconds(20),
}; };
if (triggers is not null) Options.Triggers = triggers;
var store = new InMemorySessionStore(); var store = new InMemorySessionStore();
var services = new ServiceCollection(); var services = new ServiceCollection();
if (time is not null) services.AddSingleton(time); // sessions render the prompt date from it 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); Zones = new ChatTimeZoneService(Settings, id => Chats!.Find(id)?.Session, clock, Scheduler);
Impersonation = new Impersonation(Options); 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); 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); var media = new InboundMedia(sender, Options, time ?? TimeProvider.System, NullLogger<InboundMedia>.Instance, speech);
Composer = new InboundComposer(sender, media, NullLogger<InboundComposer>.Instance); 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> /// <summary>Off until <see cref="TelegramOptions.AdminUserId"/> is set on <see cref="Options"/>.</summary>
public Impersonation Impersonation { get; } public Impersonation Impersonation { get; }
@@ -140,6 +166,9 @@ public sealed class ChatHost : IAsyncDisposable
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
if (Scheduler is not null) await Scheduler.StopAsync(CancellationToken.None); 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 Chats.DisposeAsync();
await _services.DisposeAsync(); await _services.DisposeAsync();
if (_ownsRoot) if (_ownsRoot)

View File

@@ -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> /// <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; } 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> /// <summary>Called for every non-poll request before it is answered; throw to simulate a failure.</summary>
public Action<IRequest>? OnSend { get; set; } public Action<IRequest>? OnSend { get; set; }
@@ -127,6 +130,10 @@ public sealed class FakeBotClient : ITelegramBotClient
case GetFileRequest getFile: case GetFileRequest getFile:
result = new TGFile { FileId = getFile.FileId, FileUniqueId = getFile.FileId + "-u", FilePath = $"photos/{getFile.FileId}.jpg", FileSize = 3 }; result = new TGFile { FileId = getFile.FileId, FileUniqueId = getFile.FileId + "-u", FilePath = $"photos/{getFile.FileId}.jpg", FileSize = 3 };
break; 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: case SendPhotoRequest photo:
result = RecordUpload("sendPhoto", photo.ChatId, photo.Photo, photo.Caption, photo.ParseMode); result = RecordUpload("sendPhoto", photo.ChatId, photo.Photo, photo.Caption, photo.ParseMode);
break; break;

View File

@@ -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 }; 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> /// <summary>The fake bot's user id (<see cref="FakeBotClient.BotId"/>).</summary>