Files
VolcanicAgenticCore/hosts/Volcanic.AgenticCore.Telegram/Triggers/TriggerStore.cs

225 lines
9.5 KiB
C#

using System.Globalization;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Json;
namespace Volcanic.AgenticCore.Telegram.Triggers;
/// <summary>
/// A topic trigger: a yes/no question Jev asks about every message of one group, owned by one private chat. It matches
/// when the yes-probability reaches <see cref="Threshold"/>.
/// </summary>
public sealed record Trigger(
string Id,
string Name,
long GroupId,
string GroupTitle,
string Instructions,
string? CriteriaTrue,
string? CriteriaFalse,
double Threshold,
bool Enabled,
DateTimeOffset? SnoozedUntil,
DateTimeOffset CreatedAt)
{
public bool IsSnoozed(DateTimeOffset now) => SnoozedUntil is { } until && until > now;
}
/// <summary>A trigger and the private chat it belongs to.</summary>
public sealed record WatchingTrigger(long Owner, Trigger Trigger);
/// <summary>What <see cref="TriggerStore.AddAsync"/> needs; the store assigns the id and creation time.</summary>
public sealed record NewTrigger(string Name, long GroupId, string GroupTitle, string Instructions, string? CriteriaTrue, string? CriteriaFalse, double Threshold);
public sealed class TriggerLimitException(string message) : Exception(message);
/// <summary>
/// Every owner's triggers, in <c>&lt;WorkspaceRoot&gt;/&lt;chatId&gt;/.volcanic/triggers/triggers.json</c>
/// (<c>{ "version": 1, "triggers": [ … ] }</c>). All files are loaded at construction into one index by group; every
/// change rewrites the owner's file atomically under one semaphore. A corrupt file is set aside.
/// </summary>
public sealed class TriggerStore
{
public const int Version = 1;
private readonly string _workspaceRoot;
private readonly int _maxPerChat;
private readonly ILogger _logger;
private readonly TimeProvider _time;
private readonly SemaphoreSlim _writeGate = new(1, 1);
private readonly Lock _lock = new();
private readonly Dictionary<long, List<Trigger>> _byOwner = [];
public TriggerStore(string workspaceRoot, int maxPerChat, ILogger<TriggerStore> logger, TimeProvider? time = null)
{
_workspaceRoot = workspaceRoot;
_maxPerChat = maxPerChat;
_logger = logger;
_time = time ?? TimeProvider.System;
Load();
}
/// <summary>The owner's working directory (its session's cwd).</summary>
public string WorkspaceOf(long owner) => Path.Combine(_workspaceRoot, owner.ToString(CultureInfo.InvariantCulture));
public static string DirectoryIn(string workspace) => Path.Combine(workspace, ".volcanic", "triggers");
public static string PathIn(string workspace) => Path.Combine(DirectoryIn(workspace), "triggers.json");
public int Count
{
get { lock (_lock) return _byOwner.Values.Sum(l => l.Count); }
}
public int OwnerCount
{
get { lock (_lock) return _byOwner.Values.Count(l => l.Count > 0); }
}
public IReadOnlyList<Trigger> List(long owner)
{
lock (_lock) return _byOwner.TryGetValue(owner, out var list) ? [.. list] : [];
}
public Trigger? Find(long owner, string id)
{
lock (_lock) return _byOwner.TryGetValue(owner, out var list) ? list.Find(t => string.Equals(t.Id, id, StringComparison.OrdinalIgnoreCase)) : null;
}
/// <summary>Every trigger (enabled or not) watching <paramref name="groupId"/>, across owners.</summary>
public IReadOnlyList<WatchingTrigger> Watching(long groupId)
{
lock (_lock)
return [.. _byOwner.SelectMany(kv => kv.Value.Where(t => t.GroupId == groupId).Select(t => new WatchingTrigger(kv.Key, t)))];
}
/// <summary>The soonest snooze end of any trigger, or null.</summary>
public DateTimeOffset? NextSnoozeEnd()
{
lock (_lock) return _byOwner.Values.SelectMany(l => l).Select(t => t.SnoozedUntil).Where(u => u is not null).Min();
}
/// <summary>Triggers whose snooze has ended by <paramref name="now"/> (still marked snoozed).</summary>
public IReadOnlyList<WatchingTrigger> SnoozeDue(DateTimeOffset now)
{
lock (_lock)
return [.. _byOwner.SelectMany(kv => kv.Value.Where(t => t.SnoozedUntil is { } u && u <= now).Select(t => new WatchingTrigger(kv.Key, t)))];
}
public async Task<Trigger> AddAsync(long owner, NewTrigger trigger, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(trigger);
Trigger? created = null;
await ChangeAsync(owner, list =>
{
if (list.Count >= _maxPerChat)
throw new TriggerLimitException($"This chat already has {list.Count} triggers (the limit is {_maxPerChat}); delete one first.");
var next = list.Select(t => int.TryParse(t.Id.AsSpan(1), NumberStyles.None, CultureInfo.InvariantCulture, out var n) ? n : 0).DefaultIfEmpty(0).Max() + 1;
created = new Trigger("t" + next.ToString(CultureInfo.InvariantCulture), trigger.Name, trigger.GroupId, trigger.GroupTitle, trigger.Instructions,
trigger.CriteriaTrue, trigger.CriteriaFalse, trigger.Threshold, Enabled: true, SnoozedUntil: null, _time.GetUtcNow());
list.Add(created);
return true;
}, cancellationToken).ConfigureAwait(false);
return created!;
}
/// <summary>Applies <paramref name="change"/> to the trigger; null when the owner has no such trigger.</summary>
public async Task<Trigger?> UpdateAsync(long owner, string id, Func<Trigger, Trigger> change, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(change);
Trigger? updated = null;
await ChangeAsync(owner, list =>
{
var index = list.FindIndex(t => string.Equals(t.Id, id, StringComparison.OrdinalIgnoreCase));
if (index < 0) return false;
updated = change(list[index]) with { Id = list[index].Id, CreatedAt = list[index].CreatedAt };
list[index] = updated;
return true;
}, cancellationToken).ConfigureAwait(false);
return updated;
}
public async Task<Trigger?> RemoveAsync(long owner, string id, CancellationToken cancellationToken)
{
Trigger? removed = null;
await ChangeAsync(owner, list =>
{
var index = list.FindIndex(t => string.Equals(t.Id, id, StringComparison.OrdinalIgnoreCase));
if (index < 0) return false;
removed = list[index];
list.RemoveAt(index);
return true;
}, cancellationToken).ConfigureAwait(false);
return removed;
}
/// <summary>A group upgraded to a supergroup: every trigger on <paramref name="from"/> moves to <paramref name="to"/>.</summary>
public async Task MoveGroupAsync(long from, long to, CancellationToken cancellationToken)
{
long[] owners;
lock (_lock) owners = [.. _byOwner.Where(kv => kv.Value.Any(t => t.GroupId == from)).Select(kv => kv.Key)];
foreach (var owner in owners)
await ChangeAsync(owner, list =>
{
for (var i = 0; i < list.Count; i++)
if (list[i].GroupId == from) list[i] = list[i] with { GroupId = to };
return true;
}, cancellationToken).ConfigureAwait(false);
}
private async Task ChangeAsync(long owner, Func<List<Trigger>, bool> change, CancellationToken cancellationToken)
{
await _writeGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
TriggersFile snapshot;
lock (_lock)
{
var list = _byOwner.TryGetValue(owner, out var existing) ? existing : [];
var working = new List<Trigger>(list);
if (!change(working)) return;
_byOwner[owner] = working;
snapshot = new TriggersFile(Version, working);
}
var path = PathIn(WorkspaceOf(owner));
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var temp = $"{path}.{Environment.ProcessId.ToString(CultureInfo.InvariantCulture)}.tmp";
await File.WriteAllTextAsync(temp, JsonSerializer.Serialize(snapshot, VolcanicJson.Indented), CancellationToken.None).ConfigureAwait(false);
File.Move(temp, path, overwrite: true);
}
finally
{
_writeGate.Release();
}
}
private void Load()
{
if (!Directory.Exists(_workspaceRoot)) return;
foreach (var dir in Directory.EnumerateDirectories(_workspaceRoot))
{
if (!long.TryParse(Path.GetFileName(dir), NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var owner)) continue;
var path = PathIn(dir);
if (!File.Exists(path)) continue;
TriggersFile? file;
try
{
file = VolcanicJson.Deserialize<TriggersFile>(File.ReadAllText(path));
}
catch (JsonException)
{
file = null;
}
if (file is not { Version: Version, Triggers: not null })
{
var aside = $"{path}.corrupt-{_time.GetUtcNow().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)}";
File.Move(path, aside, overwrite: true);
_logger.LogError("Triggers of chat {ChatId} ({Path}) could not be read; moved to {Aside}", owner, path, aside);
continue;
}
_byOwner[owner] = file.Triggers;
}
}
private sealed record TriggersFile(int Version, List<Trigger>? Triggers);
}