Files

247 lines
16 KiB
C#

using System.ComponentModel;
using System.Globalization;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
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,
ILogger<TriggerServices>? logger = null)
{
/// <summary>Every change the tool makes is logged here.</summary>
public ILogger Logger { get; } = logger ?? NullLogger<TriggerServices>.Instance;
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);
services.Logger.LogInformation("Chat {Owner}: trigger {TriggerId} \"{Name}\" created on group {GroupId} \"{Title}\", threshold {Threshold:0.00}: {Instructions}",
owner, created.Id, created.Name, group.Id, group.Title, created.Threshold, created.Instructions);
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.");
services.Logger.LogInformation("Chat {Owner}: trigger {TriggerId} \"{Name}\" updated: threshold {Threshold:0.00}, {State}: {Instructions}",
owner, updated.Id, updated.Name, updated.Threshold, updated.Enabled ? "on" : "off", updated.Instructions);
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.");
services.Logger.LogInformation("Chat {Owner}: trigger {TriggerId} \"{Name}\" deleted", owner, removed.Id, removed.Name);
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.");
services.Logger.LogInformation("Chat {Owner}: {Triggers} snoozed until {Until:O}", owner, string.Join(", ", snoozed.Select(t => t.Id)), until);
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;
}
}