Files
Playa/hosts/Volcanic.AgenticCore.Telegram/Triggers/TriggerPrompts.cs

142 lines
7.6 KiB
C#

using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using Volcanic.AgenticCore.Telegram.Groups;
namespace Volcanic.AgenticCore.Telegram.Triggers;
/// <summary>The texts triggers produce: the owner's prompts, list lines, and the snooze duration syntax.</summary>
public static partial class TriggerPrompts
{
/// <summary>The prompt for matches collected in one debounce window.</summary>
public static string Live(Trigger trigger, IReadOnlyList<(GroupMessage Message, double Score)> hits, TimeZoneInfo zone)
{
ArgumentNullException.ThrowIfNull(trigger);
ArgumentNullException.ThrowIfNull(hits);
var group = hits.Count > 0 ? hits[^1].Message.GroupTitle : trigger.GroupTitle;
var sb = new StringBuilder().Append(CultureInfo.InvariantCulture,
$"[Trigger \"{trigger.Name}\" ({trigger.Id}) matched {Count(hits.Count, "message")} in \"{group}\"]");
var format = TimeFormat(hits.Select(h => h.Message), zone);
foreach (var (message, score) in hits) AppendMessage(sb, message, zone, format, score);
return sb.ToString();
}
/// <summary>
/// The prompt for a snooze batch Jev found related: the whole batch (the agent picks out what matters), or, when
/// <paramref name="digestPath"/> is set, where the batch was written instead.
/// </summary>
public static string Snoozed(Trigger trigger, IReadOnlyList<GroupMessage> messages, double score, TimeZoneInfo zone, string? digestPath = null)
{
ArgumentNullException.ThrowIfNull(trigger);
ArgumentNullException.ThrowIfNull(messages);
var group = messages.Count > 0 ? messages[^1].GroupTitle : trigger.GroupTitle;
var since = messages.Count > 0 ? Stamp(messages[0].Time, zone) : "";
var sb = new StringBuilder().Append(CultureInfo.InvariantCulture,
$"[Trigger \"{trigger.Name}\" ({trigger.Id}): snooze ended; {Count(messages.Count, "message")} from \"{group}\" since {since} contain related ones (score {score.ToString("0.00", CultureInfo.InvariantCulture)})]");
if (digestPath is not null)
{
sb.Append(CultureInfo.InvariantCulture, $"\nThe messages are in {digestPath}; read it and pick out what matters.");
return sb.ToString();
}
AppendMessages(sb, messages, zone);
return sb.ToString();
}
/// <summary>The batch as plain text, for a digest file.</summary>
public static string Digest(Trigger trigger, IReadOnlyList<GroupMessage> messages, TimeZoneInfo zone)
{
ArgumentNullException.ThrowIfNull(trigger);
ArgumentNullException.ThrowIfNull(messages);
var sb = new StringBuilder().Append(CultureInfo.InvariantCulture, $"# Trigger \"{trigger.Name}\" ({trigger.Id}): messages collected while snoozed\n");
AppendMessages(sb, messages, zone);
return sb.Append('\n').ToString();
}
/// <summary>One trigger for <c>list</c> and <c>/triggers</c>.</summary>
public static string Line(Trigger trigger, TimeZoneInfo zone, DateTimeOffset now, int queued)
{
ArgumentNullException.ThrowIfNull(trigger);
var sb = new StringBuilder().Append(CultureInfo.InvariantCulture,
$"{trigger.Id} \"{trigger.Name}\" · {trigger.GroupTitle} · threshold {trigger.Threshold.ToString("0.00", CultureInfo.InvariantCulture)}");
if (!trigger.Enabled) sb.Append(" · off");
if (trigger.IsSnoozed(now) && trigger.SnoozedUntil is { } until)
sb.Append(CultureInfo.InvariantCulture, $" · snoozed until {Stamp(until, zone)}, {queued.ToString(CultureInfo.InvariantCulture)} queued");
sb.Append("\n asks: ").Append(trigger.Instructions);
if (!string.IsNullOrWhiteSpace(trigger.CriteriaTrue)) sb.Append("\n yes: ").Append(trigger.CriteriaTrue);
if (!string.IsNullOrWhiteSpace(trigger.CriteriaFalse)) sb.Append("\n no: ").Append(trigger.CriteriaFalse);
return sb.ToString();
}
/// <summary>"Mon 05 Oct 18:00" in <paramref name="zone"/>.</summary>
public static string Stamp(DateTimeOffset at, TimeZoneInfo zone) =>
TimeZoneInfo.ConvertTime(at, zone).ToString("ddd dd MMM HH:mm", CultureInfo.InvariantCulture);
/// <summary><c>90m</c>, <c>2h</c>, <c>1d</c>, <c>1h30m</c>, <c>1d 2h</c>; positive only.</summary>
public static bool TryParseDuration(string? text, out TimeSpan duration)
{
duration = TimeSpan.Zero;
var match = DurationPattern().Match(text ?? "");
if (!match.Success || (!match.Groups[1].Success && !match.Groups[2].Success && !match.Groups[3].Success)) return false;
try
{
duration = TimeSpan.FromDays(Number(match.Groups[1])) + TimeSpan.FromHours(Number(match.Groups[2])) + TimeSpan.FromMinutes(Number(match.Groups[3]));
}
catch (OverflowException)
{
return false;
}
return duration > TimeSpan.Zero;
static double Number(Group g) => g.Success ? double.Parse(g.Value, CultureInfo.InvariantCulture) : 0;
}
/// <summary>An ISO 8601 date-time; without an offset it is local time in <paramref name="zone"/>.</summary>
public static bool TryParseUntil(string? text, TimeZoneInfo zone, out DateTimeOffset until)
{
ArgumentNullException.ThrowIfNull(zone);
until = default;
var value = (text ?? "").Trim();
if (value.Length == 0) return false;
if (OffsetSuffix().IsMatch(value))
return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out until);
if (!DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var local)) return false;
try
{
until = TimeZoneInfo.ConvertTimeToUtc(DateTime.SpecifyKind(local, DateTimeKind.Unspecified), zone);
return true;
}
catch (ArgumentException) // a local time skipped by a DST change
{
return false;
}
}
private static void AppendMessages(StringBuilder sb, IReadOnlyList<GroupMessage> messages, TimeZoneInfo zone)
{
var format = TimeFormat(messages, zone);
foreach (var message in messages) AppendMessage(sb, message, zone, format, null);
}
private static void AppendMessage(StringBuilder sb, GroupMessage message, TimeZoneInfo zone, string format, double? score)
{
var time = TimeZoneInfo.ConvertTime(message.Time, zone).ToString(format, CultureInfo.InvariantCulture);
sb.Append('\n').Append(time).Append(' ').Append(message.Sender);
if (score is { } s) sb.Append(", score ").Append(s.ToString("0.00", CultureInfo.InvariantCulture));
sb.Append(": ").Append(message.Text);
if (message.ReplyTo is { } reply) sb.Append("\n in reply to ").Append(reply.Sender).Append(": ").Append(reply.Text ?? "(no text)");
if (message.Link is { } link) sb.Append("\n ").Append(link);
}
/// <summary>"HH:mm" when every message is from one local day, else with the date.</summary>
private static string TimeFormat(IEnumerable<GroupMessage> messages, TimeZoneInfo zone) =>
messages.Select(m => TimeZoneInfo.ConvertTime(m.Time, zone).Date).Distinct().Skip(1).Any() ? "dd MMM HH:mm" : "HH:mm";
private static string Count(int n, string noun) => n.ToString(CultureInfo.InvariantCulture) + " " + noun + (n == 1 ? "" : "s");
[GeneratedRegex(@"^\s*(?:(\d{1,4})\s*d)?\s*(?:(\d{1,5})\s*h)?\s*(?:(\d{1,6})\s*m(?:in)?)?\s*$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex DurationPattern();
[GeneratedRegex(@"(?:Z|[+-]\d{2}:?\d{2})$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex OffsetSuffix();
}