forked from admin/VolcanicAgenticCore
142 lines
7.6 KiB
C#
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();
|
|
}
|