diff --git a/MiniUtils/Services/AutoTombstoneFollowerService.cs b/MiniUtils/Services/AutoTombstoneFollowerService.cs
new file mode 100644
index 0000000..0b9a444
--- /dev/null
+++ b/MiniUtils/Services/AutoTombstoneFollowerService.cs
@@ -0,0 +1,85 @@
+using LibMatrix.EventTypes.Spec.State.RoomInfo;
+using LibMatrix.Filters;
+using LibMatrix.Helpers;
+using LibMatrix.Homeservers;
+
+namespace MiniUtils.Services;
+
+public class AutoTombstoneFollowerService(
+ AuthenticatedHomeserverGeneric hs,
+ ILogger<AutoTombstoneFollowerService> logger,
+ MiniUtilsConfiguration config
+)
+ : IHostedService {
+ private Task? _listenerTask;
+ private readonly CancellationTokenSource _cts = new();
+
+ /// <summary>Triggered when the application host is ready to start the service.</summary>
+ /// <param name="cancellationToken">Indicates that the start process has been aborted.</param>
+ public Task StartAsync(CancellationToken cancellationToken) {
+ if (!config.FollowTombstones) return Task.CompletedTask;
+ _listenerTask = Run(_cts.Token);
+ logger.LogInformation("Tombstone follower started (StartAsync)!");
+ return Task.CompletedTask;
+ }
+
+ private async Task? Run(CancellationToken cancellationToken) {
+ logger.LogInformation("Starting Tombstone listener!");
+ var filter = await hs.NamedCaches.FilterCache.GetOrSetValueAsync("gay.rory.miniutils.services.tombstone_follower",
+ new SyncFilter() {
+ AccountData = new SyncFilter.EventFilter(notTypes: ["*"], limit: 1),
+ Presence = new SyncFilter.EventFilter(notTypes: ["*"]),
+ Room = new SyncFilter.RoomFilter() {
+ AccountData = new SyncFilter.RoomFilter.StateFilter(notTypes: ["*"]),
+ Ephemeral = new SyncFilter.RoomFilter.StateFilter(notTypes: ["*"]),
+ State = new SyncFilter.RoomFilter.StateFilter(types: [RoomTombstoneEventContent.EventId]),
+ Timeline = new SyncFilter.RoomFilter.StateFilter(types: [RoomTombstoneEventContent.EventId]),
+ }
+ });
+
+ var syncHelper = new SyncHelper(hs, logger) {
+ FilterId = filter,
+ UseMsc4222StateAfter = true
+ };
+
+ syncHelper.SyncReceivedHandlers.Add(async sync => {
+ logger.LogInformation("Sync received!");
+ var joinedRooms = await hs.GetJoinedRooms();
+ foreach (var roomResp in sync.Rooms?.Join ?? []) {
+ if (roomResp.Value.StateAfter?.Events is null) continue;
+ foreach (var @event in roomResp.Value.StateAfter.Events) {
+ if (@event is not { Type: RoomTombstoneEventContent.EventId, StateKey: not null }) continue;
+ var replacement = @event.ContentAs<RoomTombstoneEventContent>()!.ReplacementRoom;
+ if (string.IsNullOrWhiteSpace(replacement)) {
+ logger.LogError("[{}] Tombstone event with no replacement room!", roomResp.Key);
+ continue;
+ }
+
+ var room = hs.GetRoom(roomResp.Key);
+ if (joinedRooms.Any(x => x.RoomId == replacement)) {
+ // logger.LogWarning("[{}] Replacement room {} is already joined!", roomResp.Key, replacement);
+ continue;
+ }
+
+ await room.JoinAsync(reason: "Following tombstone", homeservers: [replacement.Split(':', 2)[1]]);
+ await Task.Delay(1000, cancellationToken);
+ joinedRooms = await hs.GetJoinedRooms();
+ }
+ }
+ });
+
+ await syncHelper.RunSyncLoopAsync(cancellationToken: _cts.Token);
+ }
+
+ /// <summary>Triggered when the application host is performing a graceful shutdown.</summary>
+ /// <param name="cancellationToken">Indicates that the shutdown process should no longer be graceful.</param>
+ public async Task StopAsync(CancellationToken cancellationToken) {
+ logger.LogInformation("Shutting down command listener!");
+ if (_listenerTask is null) {
+ logger.LogError("Could not shut down command listener task because it was null!");
+ return;
+ }
+
+ await _cts.CancelAsync();
+ }
+}
\ No newline at end of file
|