forked from admin/VolcanicAgenticCore
225 lines
9.5 KiB
C#
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><WorkspaceRoot>/<chatId>/.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);
|
|
}
|