feat(telegram): group registry, trigger store and snooze queue
This commit is contained in:
71
hosts/Volcanic.AgenticCore.Telegram/Groups/GroupMessage.cs
Normal file
71
hosts/Volcanic.AgenticCore.Telegram/Groups/GroupMessage.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using System.Globalization;
|
||||
using Telegram.Bot.Types;
|
||||
using Telegram.Bot.Types.Enums;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Groups;
|
||||
|
||||
/// <summary>The message a group message replies to.</summary>
|
||||
public sealed record GroupReply(string Sender, string? Text);
|
||||
|
||||
/// <summary>A group message as triggers see it: who, where, when, the text (or caption) and what it replies to.</summary>
|
||||
public sealed record GroupMessage(
|
||||
long GroupId,
|
||||
string GroupTitle,
|
||||
string? GroupUsername,
|
||||
bool IsSupergroup,
|
||||
int MessageId,
|
||||
DateTimeOffset Time,
|
||||
string Sender,
|
||||
string? Text,
|
||||
GroupReply? ReplyTo = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// A link to the message: <c>t.me/<username>/<id></c> for public groups, <c>t.me/c/<id without -100>/<id></c>
|
||||
/// for private supergroups (members only), none for basic groups.
|
||||
/// </summary>
|
||||
public string? Link
|
||||
{
|
||||
get
|
||||
{
|
||||
var mid = MessageId.ToString(CultureInfo.InvariantCulture);
|
||||
if (!string.IsNullOrEmpty(GroupUsername)) return $"https://t.me/{GroupUsername}/{mid}";
|
||||
if (!IsSupergroup) return null;
|
||||
var id = (-GroupId).ToString(CultureInfo.InvariantCulture);
|
||||
return id.StartsWith("100", StringComparison.Ordinal) && id.Length > 3 ? $"https://t.me/c/{id[3..]}/{mid}" : null;
|
||||
}
|
||||
}
|
||||
|
||||
public static GroupMessage From(Message message)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
var reply = message.ReplyToMessage is { } replied
|
||||
? new GroupReply(SenderOf(replied), replied.Text ?? replied.Caption)
|
||||
: null;
|
||||
return new GroupMessage(
|
||||
message.Chat.Id,
|
||||
TitleOf(message.Chat),
|
||||
message.Chat.Username,
|
||||
message.Chat.Type == ChatType.Supergroup,
|
||||
message.Id,
|
||||
new DateTimeOffset(DateTime.SpecifyKind(message.Date, DateTimeKind.Utc)),
|
||||
SenderOf(message),
|
||||
message.Text ?? message.Caption,
|
||||
reply);
|
||||
}
|
||||
|
||||
public static string TitleOf(Chat chat)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(chat);
|
||||
return string.IsNullOrWhiteSpace(chat.Title) ? chat.Id.ToString(CultureInfo.InvariantCulture) : chat.Title;
|
||||
}
|
||||
|
||||
/// <summary>"First Last (@username)", or the chat an anonymous admin / channel posted as.</summary>
|
||||
public static string SenderOf(Message message)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
if (message.SenderChat is { } chat) return TitleOf(chat);
|
||||
if (message.From is not { } user) return "unknown";
|
||||
var name = string.IsNullOrWhiteSpace(user.LastName) ? user.FirstName : $"{user.FirstName} {user.LastName}";
|
||||
return string.IsNullOrWhiteSpace(user.Username) ? name : $"{name} (@{user.Username})";
|
||||
}
|
||||
}
|
||||
147
hosts/Volcanic.AgenticCore.Telegram/Groups/GroupRegistry.cs
Normal file
147
hosts/Volcanic.AgenticCore.Telegram/Groups/GroupRegistry.cs
Normal file
@@ -0,0 +1,147 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Telegram.Bot.Types;
|
||||
using Telegram.Bot.Types.Enums;
|
||||
using Volcanic.AgenticCore.Abstractions.Json;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Groups;
|
||||
|
||||
/// <summary>A group the bot watches. Inactive once the bot left or was removed; its triggers stay but never fire.</summary>
|
||||
public sealed record KnownGroup(long Id, string Title, string? Username, bool IsSupergroup, long AddedBy, DateTimeOffset Since, bool Active = true);
|
||||
|
||||
/// <summary>
|
||||
/// The groups an allowed user vouched for (added the bot, or posted while it was there), in
|
||||
/// <c><AgentDir>/telegram/groups.json</c>: <c>{ "version": 1, "groups": [ … ] }</c>. Loaded once, written atomically
|
||||
/// through one semaphore; a corrupt file is set aside and loading starts empty.
|
||||
/// </summary>
|
||||
public sealed class GroupRegistry
|
||||
{
|
||||
public const int Version = 1;
|
||||
private readonly string _path;
|
||||
private readonly ILogger _logger;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly SemaphoreSlim _writeGate = new(1, 1);
|
||||
private readonly Lock _lock = new();
|
||||
private readonly Dictionary<long, KnownGroup> _groups = [];
|
||||
|
||||
public GroupRegistry(string path, ILogger<GroupRegistry> logger, TimeProvider? time = null)
|
||||
{
|
||||
_path = path;
|
||||
_logger = logger;
|
||||
_time = time ?? TimeProvider.System;
|
||||
Load();
|
||||
}
|
||||
|
||||
public static string DefaultPath(string agentDir) => Path.Combine(agentDir, "telegram", "groups.json");
|
||||
|
||||
public KnownGroup? Find(long id)
|
||||
{
|
||||
lock (_lock) return _groups.GetValueOrDefault(id);
|
||||
}
|
||||
|
||||
public IReadOnlyList<KnownGroup> All()
|
||||
{
|
||||
lock (_lock) return [.. _groups.Values.OrderBy(g => g.Title, StringComparer.CurrentCultureIgnoreCase)];
|
||||
}
|
||||
|
||||
/// <summary>Adds the group (or reactivates it) on behalf of allowed user <paramref name="by"/>. True when it changed.</summary>
|
||||
public Task<bool> VouchAsync(Chat chat, long by, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(chat);
|
||||
return ChangeAsync(groups =>
|
||||
{
|
||||
if (groups.TryGetValue(chat.Id, out var known))
|
||||
{
|
||||
var updated = known with { Title = GroupMessage.TitleOf(chat), Username = chat.Username, IsSupergroup = chat.Type == ChatType.Supergroup, Active = true };
|
||||
if (updated == known) return false;
|
||||
groups[chat.Id] = updated;
|
||||
return true;
|
||||
}
|
||||
groups[chat.Id] = new KnownGroup(chat.Id, GroupMessage.TitleOf(chat), chat.Username, chat.Type == ChatType.Supergroup, by, _time.GetUtcNow());
|
||||
_logger.LogInformation("Group {GroupId} \"{Title}\" is now watched (vouched for by {UserId})", chat.Id, GroupMessage.TitleOf(chat), by);
|
||||
return true;
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Keeps a known group's title and username current. No-op for unknown groups or when nothing changed.</summary>
|
||||
public Task<bool> RefreshAsync(Chat chat, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(chat);
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_groups.TryGetValue(chat.Id, out var known) || (known.Title == GroupMessage.TitleOf(chat) && known.Username == chat.Username))
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
return ChangeAsync(groups =>
|
||||
{
|
||||
if (!groups.TryGetValue(chat.Id, out var known)) return false;
|
||||
groups[chat.Id] = known with { Title = GroupMessage.TitleOf(chat), Username = chat.Username };
|
||||
return true;
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<bool> SetInactiveAsync(long id, CancellationToken cancellationToken = default) => ChangeAsync(groups =>
|
||||
{
|
||||
if (!groups.TryGetValue(id, out var known) || !known.Active) return false;
|
||||
groups[id] = known with { Active = false };
|
||||
_logger.LogInformation("Group {GroupId} \"{Title}\": the bot left; its triggers are paused", id, known.Title);
|
||||
return true;
|
||||
}, cancellationToken);
|
||||
|
||||
/// <summary>A group upgraded to a supergroup gets a new id.</summary>
|
||||
public Task<bool> MigrateAsync(long from, long to, CancellationToken cancellationToken = default) => ChangeAsync(groups =>
|
||||
{
|
||||
if (!groups.Remove(from, out var known)) return false;
|
||||
groups[to] = known with { Id = to, IsSupergroup = true };
|
||||
_logger.LogInformation("Group {From} became supergroup {To}", from, to);
|
||||
return true;
|
||||
}, cancellationToken);
|
||||
|
||||
private async Task<bool> ChangeAsync(Func<Dictionary<long, KnownGroup>, bool> change, CancellationToken cancellationToken)
|
||||
{
|
||||
await _writeGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
GroupsFile snapshot;
|
||||
lock (_lock)
|
||||
{
|
||||
if (!change(_groups)) return false;
|
||||
snapshot = new GroupsFile(Version, [.. _groups.Values.OrderBy(g => g.Id)]);
|
||||
}
|
||||
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);
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private void Load()
|
||||
{
|
||||
if (!File.Exists(_path)) return;
|
||||
GroupsFile? file;
|
||||
try
|
||||
{
|
||||
file = VolcanicJson.Deserialize<GroupsFile>(File.ReadAllText(_path));
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
file = null;
|
||||
}
|
||||
if (file is not { Version: Version, Groups: not null })
|
||||
{
|
||||
var aside = $"{_path}.corrupt-{_time.GetUtcNow().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)}";
|
||||
File.Move(_path, aside, overwrite: true);
|
||||
_logger.LogError("Groups file {Path} could not be read; moved to {Aside}, no group is watched until an allowed user vouches again", _path, aside);
|
||||
return;
|
||||
}
|
||||
foreach (var group in file.Groups) _groups[group.Id] = group;
|
||||
}
|
||||
|
||||
private sealed record GroupsFile(int Version, List<KnownGroup>? Groups);
|
||||
}
|
||||
104
hosts/Volcanic.AgenticCore.Telegram/Triggers/SnoozeQueue.cs
Normal file
104
hosts/Volcanic.AgenticCore.Telegram/Triggers/SnoozeQueue.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Volcanic.AgenticCore.Abstractions.Json;
|
||||
using Volcanic.AgenticCore.Telegram.Groups;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Triggers;
|
||||
|
||||
/// <summary>
|
||||
/// The messages a snoozed trigger collects: <c><ownerWorkspace>/.volcanic/triggers/snoozed/<triggerId>.jsonl</c>,
|
||||
/// one <see cref="GroupMessage"/> per line, so a restart during a snooze loses nothing. Past
|
||||
/// <c>maxPerTrigger</c> the oldest lines are dropped. One gate for every file: appends are rare and small.
|
||||
/// </summary>
|
||||
public sealed class SnoozeQueue(int maxPerTrigger)
|
||||
{
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
public static string PathFor(string workspace, string triggerId) =>
|
||||
Path.Combine(TriggerStore.DirectoryIn(workspace), "snoozed", triggerId + ".jsonl");
|
||||
|
||||
/// <summary>Appends the message; returns the queue length and whether the oldest message was dropped to make room.</summary>
|
||||
public async Task<(int Count, bool Dropped)> AppendAsync(string workspace, string triggerId, GroupMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = PathFor(workspace, triggerId);
|
||||
var line = JsonSerializer.Serialize(message, VolcanicJson.Options);
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
var lines = File.Exists(path) ? File.ReadAllLines(path).Where(l => l.Length > 0).ToList() : [];
|
||||
if (lines.Count < maxPerTrigger)
|
||||
{
|
||||
await File.AppendAllTextAsync(path, line + "\n", CancellationToken.None).ConfigureAwait(false);
|
||||
return (lines.Count + 1, false);
|
||||
}
|
||||
lines.RemoveRange(0, lines.Count - maxPerTrigger + 1);
|
||||
lines.Add(line);
|
||||
await File.WriteAllTextAsync(path, string.Join('\n', lines) + "\n", CancellationToken.None).ConfigureAwait(false);
|
||||
return (lines.Count, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads and deletes the queue in one step, so nothing appended in between is lost.</summary>
|
||||
public async Task<IReadOnlyList<GroupMessage>> TakeAllAsync(string workspace, string triggerId, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = PathFor(workspace, triggerId);
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path)) return [];
|
||||
var lines = await File.ReadAllLinesAsync(path, Encoding.UTF8, CancellationToken.None).ConfigureAwait(false);
|
||||
File.Delete(path);
|
||||
var messages = new List<GroupMessage>(lines.Length);
|
||||
foreach (var l in lines)
|
||||
{
|
||||
if (l.Length == 0) continue;
|
||||
try
|
||||
{
|
||||
if (VolcanicJson.Deserialize<GroupMessage>(l) is { } m) messages.Add(m);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// a torn line from a crash mid-append: skip it
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public int Count(string workspace, string triggerId)
|
||||
{
|
||||
var path = PathFor(workspace, triggerId);
|
||||
_gate.Wait();
|
||||
try
|
||||
{
|
||||
return File.Exists(path) ? File.ReadLines(path).Count(l => l.Length > 0) : 0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string workspace, string triggerId, CancellationToken cancellationToken)
|
||||
{
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var path = PathFor(workspace, triggerId);
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
214
hosts/Volcanic.AgenticCore.Telegram/Triggers/TriggerStore.cs
Normal file
214
hosts/Volcanic.AgenticCore.Telegram/Triggers/TriggerStore.cs
Normal file
@@ -0,0 +1,214 @@
|
||||
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 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);
|
||||
}
|
||||
156
tests/Volcanic.AgenticCore.Telegram.Tests/TriggerStoreTests.cs
Normal file
156
tests/Volcanic.AgenticCore.Telegram.Tests/TriggerStoreTests.cs
Normal file
@@ -0,0 +1,156 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Telegram.Bot.Types;
|
||||
using Telegram.Bot.Types.Enums;
|
||||
using Volcanic.AgenticCore.Telegram.Groups;
|
||||
using Volcanic.AgenticCore.Telegram.Triggers;
|
||||
|
||||
namespace Volcanic.AgenticCore.Telegram.Tests;
|
||||
|
||||
/// <summary>The group registry, the trigger store and the snooze queue: persistence and bookkeeping.</summary>
|
||||
public sealed class TriggerStoreTests : IDisposable
|
||||
{
|
||||
private const long Owner = 1001;
|
||||
private const long OtherOwner = 2002;
|
||||
private const long Group = -1001234567890;
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "volcanic-triggers", Guid.NewGuid().ToString("N"));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_root, true); } catch (IOException) { }
|
||||
}
|
||||
|
||||
private TriggerStore Store(int max = 20) => new(Path.Combine(_root, "workspaces"), max, NullLogger<TriggerStore>.Instance);
|
||||
|
||||
private static NewTrigger Deploys(long group = Group) => new("deploys", group, "Dev Team", "Is this about deploys?", "Mentions deploys", "Small talk", 0.7);
|
||||
|
||||
internal static GroupMessage Message(string text, int id = 1, string sender = "Alice (@alice)", long group = Group) =>
|
||||
new(group, "Dev Team", null, true, id, new DateTimeOffset(2026, 10, 5, 12, 2, 0, TimeSpan.Zero), sender, text);
|
||||
|
||||
[Fact]
|
||||
public async Task Triggers_get_sequential_ids_per_owner_and_survive_a_reload()
|
||||
{
|
||||
var store = Store();
|
||||
Assert.Equal("t1", (await store.AddAsync(Owner, Deploys(), CancellationToken.None)).Id);
|
||||
Assert.Equal("t2", (await store.AddAsync(Owner, Deploys(), CancellationToken.None)).Id);
|
||||
Assert.Equal("t1", (await store.AddAsync(OtherOwner, Deploys(), CancellationToken.None)).Id);
|
||||
await store.RemoveAsync(Owner, "t1", CancellationToken.None);
|
||||
Assert.Equal("t3", (await store.AddAsync(Owner, Deploys(), CancellationToken.None)).Id);
|
||||
|
||||
var reloaded = Store();
|
||||
Assert.Equal(["t2", "t3"], reloaded.List(Owner).Select(t => t.Id));
|
||||
Assert.Equal(3, reloaded.Watching(Group).Count);
|
||||
Assert.True(File.Exists(TriggerStore.PathIn(reloaded.WorkspaceOf(Owner))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_per_chat_limit_is_enforced()
|
||||
{
|
||||
var store = Store(max: 1);
|
||||
await store.AddAsync(Owner, Deploys(), CancellationToken.None);
|
||||
await Assert.ThrowsAsync<TriggerLimitException>(() => store.AddAsync(Owner, Deploys(), CancellationToken.None));
|
||||
await store.AddAsync(OtherOwner, Deploys(), CancellationToken.None); // per chat, not global
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Updates_keep_id_and_creation_time_and_snoozes_are_tracked()
|
||||
{
|
||||
var store = Store();
|
||||
var t = await store.AddAsync(Owner, Deploys(), CancellationToken.None);
|
||||
var until = DateTimeOffset.UtcNow.AddHours(1);
|
||||
|
||||
var updated = await store.UpdateAsync(Owner, "T1", x => x with { Id = "hijack", Threshold = 0.9, SnoozedUntil = until }, CancellationToken.None);
|
||||
|
||||
Assert.Equal(("t1", t.CreatedAt, 0.9), (updated!.Id, updated.CreatedAt, updated.Threshold));
|
||||
Assert.Equal(until, store.NextSnoozeEnd());
|
||||
Assert.Empty(store.SnoozeDue(until.AddSeconds(-1)));
|
||||
Assert.Single(store.SnoozeDue(until));
|
||||
Assert.Null(await store.UpdateAsync(Owner, "t9", x => x, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Moving_a_group_moves_every_owners_triggers()
|
||||
{
|
||||
var store = Store();
|
||||
await store.AddAsync(Owner, Deploys(-5), CancellationToken.None);
|
||||
await store.AddAsync(OtherOwner, Deploys(-5), CancellationToken.None);
|
||||
|
||||
await store.MoveGroupAsync(-5, Group, CancellationToken.None);
|
||||
|
||||
Assert.Empty(store.Watching(-5));
|
||||
Assert.Equal(2, Store().Watching(Group).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_corrupt_file_is_set_aside()
|
||||
{
|
||||
var store = Store();
|
||||
var path = TriggerStore.PathIn(store.WorkspaceOf(Owner));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
await File.WriteAllTextAsync(path, "{ not json");
|
||||
|
||||
Assert.Empty(Store().List(Owner));
|
||||
Assert.Single(Directory.GetFiles(Path.GetDirectoryName(path)!, "triggers.json.corrupt-*"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_snooze_queue_caps_drops_the_oldest_and_takes_all_at_once()
|
||||
{
|
||||
var queue = new SnoozeQueue(maxPerTrigger: 2);
|
||||
var ws = Path.Combine(_root, "ws");
|
||||
Assert.Equal((1, false), await queue.AppendAsync(ws, "t1", Message("one", 1), CancellationToken.None));
|
||||
Assert.Equal((2, false), await queue.AppendAsync(ws, "t1", Message("two", 2), CancellationToken.None));
|
||||
Assert.Equal((2, true), await queue.AppendAsync(ws, "t1", Message("three", 3), CancellationToken.None));
|
||||
Assert.Equal(2, queue.Count(ws, "t1"));
|
||||
|
||||
var taken = await queue.TakeAllAsync(ws, "t1", CancellationToken.None);
|
||||
|
||||
Assert.Equal(["two", "three"], taken.Select(m => m.Text));
|
||||
Assert.Equal(Message("two", 2), taken[0]);
|
||||
Assert.Equal(0, queue.Count(ws, "t1"));
|
||||
Assert.Empty(await queue.TakeAllAsync(ws, "t1", CancellationToken.None));
|
||||
await queue.DeleteAsync(Path.Combine(_root, "missing"), "t1", CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Groups_are_vouched_refreshed_paused_migrated_and_persisted()
|
||||
{
|
||||
var path = GroupRegistry.DefaultPath(Path.Combine(_root, "agent"));
|
||||
var registry = new GroupRegistry(path, NullLogger<GroupRegistry>.Instance);
|
||||
var chat = new Chat { Id = -5, Type = ChatType.Group, Title = "Dev" };
|
||||
|
||||
Assert.True(await registry.VouchAsync(chat, Owner));
|
||||
Assert.False(await registry.VouchAsync(chat, Owner));
|
||||
Assert.True(await registry.RefreshAsync(new Chat { Id = -5, Type = ChatType.Group, Title = "Dev Team" }));
|
||||
Assert.False(await registry.RefreshAsync(new Chat { Id = -9, Type = ChatType.Group, Title = "Unknown" }));
|
||||
Assert.True(await registry.SetInactiveAsync(-5));
|
||||
Assert.False(registry.Find(-5)!.Active);
|
||||
Assert.True(await registry.VouchAsync(new Chat { Id = -5, Type = ChatType.Group, Title = "Dev Team" }, Owner));
|
||||
Assert.True(registry.Find(-5)!.Active);
|
||||
Assert.True(await registry.MigrateAsync(-5, Group));
|
||||
|
||||
var reloaded = new GroupRegistry(path, NullLogger<GroupRegistry>.Instance);
|
||||
var group = Assert.Single(reloaded.All());
|
||||
Assert.Equal((Group, "Dev Team", true, Owner, true), (group.Id, group.Title, group.IsSupergroup, group.AddedBy, group.Active));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Message_links_and_senders()
|
||||
{
|
||||
Assert.Equal("https://t.me/c/1234567890/7", (Message("x") with { MessageId = 7 }).Link);
|
||||
Assert.Equal("https://t.me/devteam/7", (Message("x") with { MessageId = 7, GroupUsername = "devteam" }).Link);
|
||||
Assert.Null((Message("x") with { IsSupergroup = false, GroupId = -5 }).Link);
|
||||
|
||||
var message = new Message
|
||||
{
|
||||
Id = 3,
|
||||
Chat = new Chat { Id = Group, Type = ChatType.Supergroup, Title = "Dev Team" },
|
||||
From = new User { Id = 7, FirstName = "Alice", LastName = "Smith", Username = "alice" },
|
||||
Caption = "deploy log attached",
|
||||
Date = new DateTime(2026, 10, 5, 12, 0, 0, DateTimeKind.Utc),
|
||||
ReplyToMessage = new Message { Id = 2, Chat = new Chat { Id = Group }, From = new User { Id = 8, FirstName = "Bob" }, Text = "status?" },
|
||||
};
|
||||
var built = GroupMessage.From(message);
|
||||
Assert.Equal(("Alice Smith (@alice)", "deploy log attached", "Bob", "status?"), (built.Sender, built.Text, built.ReplyTo!.Sender, built.ReplyTo.Text));
|
||||
Assert.Equal("Dev Team", GroupMessage.SenderOf(new Message { Chat = new Chat { Id = Group }, SenderChat = new Chat { Id = Group, Title = "Dev Team" } }));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user