From 32a474ae32b4e1da099176d779e030dfbf70ed5a Mon Sep 17 00:00:00 2001 From: Rory& Date: Fri, 2 May 2025 16:33:05 +0200 Subject: Add support for ignoring users, change internal API --- LibMatrix | 2 +- MatrixAntiDmSpam.Core/AntiDmSpamConfiguration.cs | 2 + .../Classes/MADSIgnoreMetadataContent.cs | 25 +++++ MatrixAntiDmSpam.Core/InviteHandler.cs | 92 ------------------ MatrixAntiDmSpam.Core/PolicyExecutor.cs | 88 ++++++++++++++++- MatrixAntiDmSpam.Core/PolicyStore.cs | 104 ++++++++++++++++++--- MatrixAntiDmSpam.Core/RoomInviteHandler.cs | 92 ++++++++++++++++++ MatrixAntiDmSpam.sln.DotSettings.user | 1 + MatrixAntiDmSpam/Program.cs | 2 +- MatrixAntiDmSpam/appsettings.Development.json | 6 ++ 10 files changed, 304 insertions(+), 110 deletions(-) create mode 100644 MatrixAntiDmSpam.Core/Classes/MADSIgnoreMetadataContent.cs delete mode 100644 MatrixAntiDmSpam.Core/InviteHandler.cs create mode 100644 MatrixAntiDmSpam.Core/RoomInviteHandler.cs diff --git a/LibMatrix b/LibMatrix index 1c30aec..2fde2d5 160000 --- a/LibMatrix +++ b/LibMatrix @@ -1 +1 @@ -Subproject commit 1c30aec46b495f1da87c3a6adbda3e19e014b557 +Subproject commit 2fde2d5f961eabf3167280ba55786cdb6b38f2c0 diff --git a/MatrixAntiDmSpam.Core/AntiDmSpamConfiguration.cs b/MatrixAntiDmSpam.Core/AntiDmSpamConfiguration.cs index fe68e8a..bd06db3 100644 --- a/MatrixAntiDmSpam.Core/AntiDmSpamConfiguration.cs +++ b/MatrixAntiDmSpam.Core/AntiDmSpamConfiguration.cs @@ -6,6 +6,8 @@ public class AntiDmSpamConfiguration { public AntiDmSpamConfiguration(IConfiguration config) => config.GetRequiredSection("AntiDmSpam").Bind(this); public string? LogRoom { get; set; } public bool LogInviteDataAsFile { get; set; } + public bool IgnoreBannedUsers { get; set; } + public bool ReportBlockedInvites { get; set; } public required List PolicyLists { get; set; } diff --git a/MatrixAntiDmSpam.Core/Classes/MADSIgnoreMetadataContent.cs b/MatrixAntiDmSpam.Core/Classes/MADSIgnoreMetadataContent.cs new file mode 100644 index 0000000..c9ccdb1 --- /dev/null +++ b/MatrixAntiDmSpam.Core/Classes/MADSIgnoreMetadataContent.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; + +namespace MatrixAntiDmSpam.Core.Classes; + +public class MadsIgnoreMetadataContent { + public const string EventId = "gay.rory.MatrixAntiDmSpam.ignore_metadata"; + + // Whether the ignore entry already existed, if true, do not remove when no matching policies exist + [JsonPropertyName("was_user_added")] + public required bool WasUserAdded { get; set; } + + [JsonPropertyName("policies")] + public required List Policies { get; set; } + + public class PolicyEventReference { + [JsonPropertyName("room_id")] + public required string RoomId { get; set; } + + [JsonPropertyName("type")] + public required string Type { get; set; } + + [JsonPropertyName("state_key")] + public required string StateKey { get; set; } + } +} \ No newline at end of file diff --git a/MatrixAntiDmSpam.Core/InviteHandler.cs b/MatrixAntiDmSpam.Core/InviteHandler.cs deleted file mode 100644 index 41f4011..0000000 --- a/MatrixAntiDmSpam.Core/InviteHandler.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Text.Json; -using ArcaneLibs; -using ArcaneLibs.Extensions; -using LibMatrix.Helpers; -using LibMatrix.RoomTypes; -using LibMatrix.Utilities.Bot.Interfaces; -using Microsoft.Extensions.Logging; - -namespace MatrixAntiDmSpam.Core; - -public class RoomInviteHandler(ILogger logger, AntiDmSpamConfiguration config) : IRoomInviteHandler { - public List Invites { get; } = []; - - public List> OnInviteReceived { get; set; } = []; - - private GenericRoom? LogRoom { get; set; } - - public async Task HandleInviteAsync(RoomInviteContext invite) { - if (!string.IsNullOrWhiteSpace(config.LogRoom)) - LogRoom = invite.Homeserver.GetRoom(config.LogRoom); - - Invites.Add(invite); - await LogInvite(invite); - foreach (var handler in OnInviteReceived) { - try { - await handler.Invoke(invite); - } - catch (Exception e) { - if (!string.IsNullOrWhiteSpace(config.LogRoom)) { - var logRoom = invite.Homeserver.GetRoom(config.LogRoom); - await logRoom.SendMessageEventAsync( - new MessageBuilder() - .WithBody($"Failed to execute invite handler {handler}...").WithNewline() - .WithCollapsibleSection("Stack trace", msb => msb.WithCodeBlock(e.ToString(), "cs")) - .Build() - ); - } - } - } - } - - private async Task LogInvite(RoomInviteContext invite) { - logger.LogInformation("Received invite to {} from {}", invite.RoomId, invite.MemberEvent.Sender); - - if (LogRoom is null) return; - var inviteData = invite.InviteData.ToJsonUtf8Bytes(ignoreNull: true); - - var inviterNameTask = invite.TryGetInviterNameAsync(); - var roomNameTask = invite.TryGetRoomNameAsync(); - var inviteDataFileUriTask = invite.Homeserver.UploadFile(invite.RoomId + ".json", inviteData, "application/json"); - logger.LogInformation("Uploaded invite data ({}) to {}", Util.BytesToString(inviteData.Length), await inviteDataFileUriTask); - - await Task.WhenAll(inviterNameTask, roomNameTask, inviteDataFileUriTask); - - var message = new MessageBuilder() - .WithBody("Received invite to ").WithMention(invite.RoomId, await roomNameTask).WithBody(" from ").WithMention(invite.MemberEvent.Sender!, await inviterNameTask) - .Build(); - - message.AdditionalData!["gay.rory.invite_logger.invite_data_uri"] = JsonDocument.Parse($"\"{await inviteDataFileUriTask}\"").RootElement; - - await LogRoom.SendMessageEventAsync(message); - - if (config.LogInviteDataAsFile) { - await LogRoom.SendMessageEventAsync(new() { - MessageType = "m.file", - Body = invite.RoomId + ".json", - FileName = invite.RoomId + ".json", - Url = await inviteDataFileUriTask, - FileInfo = new() { Size = inviteData.Length, MimeType = "application/json" } - }); - } - } - - public async Task RejectInvite(RoomInviteContext invite, MessageBuilder reason) { - if (LogRoom is not null) - _ = LogRoom.SendMessageEventAsync(reason.Build()); - - try { - await invite.Homeserver.GetRoom(invite.RoomId).LeaveAsync(); - } - catch (Exception e) { - if (LogRoom is not null) - await LogRoom.SendMessageEventAsync( - new MessageBuilder().WithColoredBody("#FF8800", $"Failed to leave {invite.RoomId}:").WithNewline() - .WithCodeBlock(e.ToString(), "cs") - .Build() - ); - } - - Invites.Remove(invite); - } -} \ No newline at end of file diff --git a/MatrixAntiDmSpam.Core/PolicyExecutor.cs b/MatrixAntiDmSpam.Core/PolicyExecutor.cs index 4dcaf2b..23d70a8 100644 --- a/MatrixAntiDmSpam.Core/PolicyExecutor.cs +++ b/MatrixAntiDmSpam.Core/PolicyExecutor.cs @@ -1,11 +1,14 @@ using System.Diagnostics; using ArcaneLibs.Attributes; using ArcaneLibs.Extensions; +using LibMatrix; +using LibMatrix.EventTypes.Spec; using LibMatrix.EventTypes.Spec.State.Policy; using LibMatrix.Helpers; using LibMatrix.Homeservers; using LibMatrix.RoomTypes; using LibMatrix.Utilities.Bot.Interfaces; +using MatrixAntiDmSpam.Core.Classes; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -21,7 +24,11 @@ public class PolicyExecutor( public Task StartAsync(CancellationToken cancellationToken) { roomInviteHandler.OnInviteReceived.Add(CheckPoliciesAgainstInvite); - policyStore.OnPolicyUpdated.Add(CheckPolicyAgainstOutstandingInvites); + policyStore.OnPolicyAdded.Add(CheckPolicyAgainstOutstandingInvites); + if (config.IgnoreBannedUsers) { + policyStore.OnPoliciesChanged.Add(UpdateIgnoreList); + } + return Task.CompletedTask; } @@ -29,6 +36,76 @@ public class PolicyExecutor( return Task.CompletedTask; } +#region Feature: Manage ignore list + + private async Task UpdateIgnoreList(( + List NewPolicies, + List<(StateEventResponse Old, StateEventResponse New)> UpdatedPolicies, + List<(StateEventResponse Old, StateEventResponse New)> RemovedPolicies) updates + ) { + var ignoreListContent = await homeserver.GetIgnoredUserListAsync(); + + foreach (var newEvent in updates.NewPolicies) { + var content = newEvent.TypedContent as PolicyRuleEventContent; + if (content.Entity is null || content.IsGlobRule()) continue; + if (content.GetNormalizedRecommendation() != "m.ban") continue; + + var policyEventReference = new MadsIgnoreMetadataContent.PolicyEventReference() { + Type = newEvent.Type, + RoomId = newEvent.RoomId, + StateKey = newEvent.StateKey! + }; + + if (ignoreListContent.IgnoredUsers.TryGetValue(content.Entity, out var existingRule)) { + if (existingRule.AdditionalData?.ContainsKey(MadsIgnoreMetadataContent.EventId) ?? false) { + var existingMetadata = existingRule.GetAdditionalData(MadsIgnoreMetadataContent.EventId); + existingMetadata.Policies.Add(policyEventReference); + } + else { + existingRule.AdditionalData ??= new(); + existingRule.AdditionalData.Add(MadsIgnoreMetadataContent.EventId, new MadsIgnoreMetadataContent { + WasUserAdded = true, + Policies = [policyEventReference] + }); + } + } + else { + ignoreListContent.IgnoredUsers[content.Entity] = new() { + AdditionalData = new() { + [MadsIgnoreMetadataContent.EventId] = new MadsIgnoreMetadataContent { + WasUserAdded = false, + Policies = [policyEventReference] + } + } + }; + } + } + + foreach (var (previousEvent, newEvent) in updates.RemovedPolicies) { + if (previousEvent.Type != UserPolicyRuleEventContent.EventId) continue; + var previousContent = previousEvent.ContentAs(); + + if (previousContent.Entity is null || previousContent.IsGlobRule()) continue; + if (previousContent.GetNormalizedRecommendation() != "m.ban") continue; + + var ignoreList = await homeserver.GetIgnoredUserListAsync(); + if (ignoreList.IgnoredUsers.TryGetValue(previousContent.Entity, out var existingRule)) { + if (existingRule.AdditionalData?.ContainsKey(MadsIgnoreMetadataContent.EventId) ?? false) { + var existingMetadata = existingRule.GetAdditionalData(MadsIgnoreMetadataContent.EventId); + existingMetadata.Policies.RemoveAll(x => x.Type == previousEvent.Type && x.RoomId == previousEvent.RoomId && x.StateKey == previousEvent.StateKey); + if (!existingMetadata.WasUserAdded) + ignoreList.IgnoredUsers.Remove(previousContent.Entity); + } + } + } + + await homeserver.SetAccountDataAsync(IgnoredUserListEventContent.EventId, ignoreListContent); + } + +#endregion + +#region Feature: Reject invites + private Task CheckPoliciesAgainstInvite(RoomInviteContext invite) { logger.LogInformation("Checking policies against invite"); var sw = Stopwatch.StartNew(); @@ -44,9 +121,9 @@ public class PolicyExecutor( return Task.CompletedTask; } - private async Task CheckPolicyAgainstOutstandingInvites(PolicyRuleEventContent policy) { + private async Task CheckPolicyAgainstOutstandingInvites(StateEventResponse newEvent) { var tasks = roomInviteHandler.Invites - .Select(invite => CheckPolicyAgainstInvite(invite, policy)) + .Select(invite => CheckPolicyAgainstInvite(invite, newEvent)) .Where(x => x is not null) .Cast() // from Task? .ToList(); @@ -54,7 +131,8 @@ public class PolicyExecutor( await Task.WhenAll(tasks); } - private Task? CheckPolicyAgainstInvite(RoomInviteContext invite, PolicyRuleEventContent policy) { + private Task? CheckPolicyAgainstInvite(RoomInviteContext invite, StateEventResponse policyEvent) { + var policy = policyEvent.TypedContent as PolicyRuleEventContent ?? throw new InvalidOperationException("Policy is null"); if (policy.Recommendation != "m.ban") return null; var policyMatches = false; @@ -91,4 +169,6 @@ public class PolicyExecutor( } }); } + +#endregion } \ No newline at end of file diff --git a/MatrixAntiDmSpam.Core/PolicyStore.cs b/MatrixAntiDmSpam.Core/PolicyStore.cs index 9c439fa..0dfd490 100644 --- a/MatrixAntiDmSpam.Core/PolicyStore.cs +++ b/MatrixAntiDmSpam.Core/PolicyStore.cs @@ -1,24 +1,104 @@ +using System.Diagnostics; using LibMatrix; using LibMatrix.EventTypes.Spec.State.Policy; +using Microsoft.Extensions.Logging; namespace MatrixAntiDmSpam.Core; -public class PolicyStore { - public Dictionary AllPolicies { get; } = []; - public List> OnPolicyUpdated { get; } = []; +public class PolicyStore(ILogger logger) { + public Dictionary AllPolicies { get; } = []; - public Task AddPoliciesAsync(IEnumerable events) => Task.WhenAll(events.Select(AddPolicyAsync).ToList()); +#region Single policy events - public async Task AddPolicyAsync(StateEventResponse evt) { - var eventKey = $"{evt.RoomId}:{evt.Type}:{evt.StateKey}"; - if (evt.TypedContent is PolicyRuleEventContent policy) { - if (policy.Recommendation == "m.ban") - AllPolicies[eventKey] = policy; - else AllPolicies.Remove(eventKey); + /// + /// Fired when any policy event is received + /// + public List> OnPolicyReceived { get; } = []; - foreach (var callback in OnPolicyUpdated) { - await callback(policy); + /// + /// Fired when a new policy is added + /// + public List> OnPolicyAdded { get; } = []; + + /// + /// Fired when a policy is updated, without being removed. + /// + public List> OnPolicyUpdated { get; } = []; + + /// + /// Fired when a policy is removed. + /// + public List> OnPolicyRemoved { get; } = []; + +#endregion + +#region Bulk policy events + + /// + /// Fired when any policy event is received + /// + public List NewPolicies, + List<(StateEventResponse Old, StateEventResponse New)> UpdatedPolicies, + List<(StateEventResponse Old, StateEventResponse New)> RemovedPolicies), Task>> OnPoliciesChanged { get; } = []; + +#endregion + + public async Task AddPoliciesAsync(IEnumerable events) { + var policyEvents = events + .Where(evt => evt.TypedContent is PolicyRuleEventContent) + .ToList(); + List newPolicies = new(); + List<(StateEventResponse Old, StateEventResponse New)> updatedPolicies = new(); + List<(StateEventResponse Old, StateEventResponse New)> removedPolicies = new(); + + var sw = Stopwatch.StartNew(); + try { + foreach (var evt in policyEvents) { + var eventKey = $"{evt.RoomId}:{evt.Type}:{evt.StateKey}"; + if (evt.TypedContent is not PolicyRuleEventContent policy) continue; + if (policy.GetNormalizedRecommendation() is "m.ban") { + if (AllPolicies.TryGetValue(eventKey, out var oldContent)) + updatedPolicies.Add((oldContent, evt)); + else + newPolicies.Add(evt); + AllPolicies[eventKey] = evt; + } + else if (AllPolicies.Remove(eventKey, out var oldContent)) + removedPolicies.Add((oldContent, evt)); } } + catch (Exception e) { + Console.WriteLine(e); + } + + logger.LogInformation("Processed {Count} policies in {Elapsed}", policyEvents.Count, sw.Elapsed); + + + // execute all the callbacks in parallel, as much as possible... + await Task.WhenAll( + Task.WhenAll( + policyEvents.Select(evt => + Task.WhenAll(OnPolicyReceived.Select(callback => callback(evt)).ToList()) + ) + ), + Task.WhenAll( + newPolicies.Select(evt => + Task.WhenAll(OnPolicyAdded.Select(callback => callback(evt)).ToList()) + ) + ), + Task.WhenAll( + updatedPolicies.Select(evt => + Task.WhenAll(OnPolicyUpdated.Select(callback => callback(evt.Old, evt.New)).ToList()) + ) + ), + Task.WhenAll( + removedPolicies.Select(evt => + Task.WhenAll(OnPolicyRemoved.Select(callback => callback(evt.Old, evt.New)).ToList()) + ) + ), + Task.WhenAll(OnPoliciesChanged.Select(callback => callback((newPolicies, updatedPolicies, removedPolicies))).ToList()) + ); } + + private async Task AddPolicyAsync(StateEventResponse evt) { } } \ No newline at end of file diff --git a/MatrixAntiDmSpam.Core/RoomInviteHandler.cs b/MatrixAntiDmSpam.Core/RoomInviteHandler.cs new file mode 100644 index 0000000..41f4011 --- /dev/null +++ b/MatrixAntiDmSpam.Core/RoomInviteHandler.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using ArcaneLibs; +using ArcaneLibs.Extensions; +using LibMatrix.Helpers; +using LibMatrix.RoomTypes; +using LibMatrix.Utilities.Bot.Interfaces; +using Microsoft.Extensions.Logging; + +namespace MatrixAntiDmSpam.Core; + +public class RoomInviteHandler(ILogger logger, AntiDmSpamConfiguration config) : IRoomInviteHandler { + public List Invites { get; } = []; + + public List> OnInviteReceived { get; set; } = []; + + private GenericRoom? LogRoom { get; set; } + + public async Task HandleInviteAsync(RoomInviteContext invite) { + if (!string.IsNullOrWhiteSpace(config.LogRoom)) + LogRoom = invite.Homeserver.GetRoom(config.LogRoom); + + Invites.Add(invite); + await LogInvite(invite); + foreach (var handler in OnInviteReceived) { + try { + await handler.Invoke(invite); + } + catch (Exception e) { + if (!string.IsNullOrWhiteSpace(config.LogRoom)) { + var logRoom = invite.Homeserver.GetRoom(config.LogRoom); + await logRoom.SendMessageEventAsync( + new MessageBuilder() + .WithBody($"Failed to execute invite handler {handler}...").WithNewline() + .WithCollapsibleSection("Stack trace", msb => msb.WithCodeBlock(e.ToString(), "cs")) + .Build() + ); + } + } + } + } + + private async Task LogInvite(RoomInviteContext invite) { + logger.LogInformation("Received invite to {} from {}", invite.RoomId, invite.MemberEvent.Sender); + + if (LogRoom is null) return; + var inviteData = invite.InviteData.ToJsonUtf8Bytes(ignoreNull: true); + + var inviterNameTask = invite.TryGetInviterNameAsync(); + var roomNameTask = invite.TryGetRoomNameAsync(); + var inviteDataFileUriTask = invite.Homeserver.UploadFile(invite.RoomId + ".json", inviteData, "application/json"); + logger.LogInformation("Uploaded invite data ({}) to {}", Util.BytesToString(inviteData.Length), await inviteDataFileUriTask); + + await Task.WhenAll(inviterNameTask, roomNameTask, inviteDataFileUriTask); + + var message = new MessageBuilder() + .WithBody("Received invite to ").WithMention(invite.RoomId, await roomNameTask).WithBody(" from ").WithMention(invite.MemberEvent.Sender!, await inviterNameTask) + .Build(); + + message.AdditionalData!["gay.rory.invite_logger.invite_data_uri"] = JsonDocument.Parse($"\"{await inviteDataFileUriTask}\"").RootElement; + + await LogRoom.SendMessageEventAsync(message); + + if (config.LogInviteDataAsFile) { + await LogRoom.SendMessageEventAsync(new() { + MessageType = "m.file", + Body = invite.RoomId + ".json", + FileName = invite.RoomId + ".json", + Url = await inviteDataFileUriTask, + FileInfo = new() { Size = inviteData.Length, MimeType = "application/json" } + }); + } + } + + public async Task RejectInvite(RoomInviteContext invite, MessageBuilder reason) { + if (LogRoom is not null) + _ = LogRoom.SendMessageEventAsync(reason.Build()); + + try { + await invite.Homeserver.GetRoom(invite.RoomId).LeaveAsync(); + } + catch (Exception e) { + if (LogRoom is not null) + await LogRoom.SendMessageEventAsync( + new MessageBuilder().WithColoredBody("#FF8800", $"Failed to leave {invite.RoomId}:").WithNewline() + .WithCodeBlock(e.ToString(), "cs") + .Build() + ); + } + + Invites.Remove(invite); + } +} \ No newline at end of file diff --git a/MatrixAntiDmSpam.sln.DotSettings.user b/MatrixAntiDmSpam.sln.DotSettings.user index 73d444d..159add8 100644 --- a/MatrixAntiDmSpam.sln.DotSettings.user +++ b/MatrixAntiDmSpam.sln.DotSettings.user @@ -2,6 +2,7 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded diff --git a/MatrixAntiDmSpam/Program.cs b/MatrixAntiDmSpam/Program.cs index fc1d603..935e23f 100644 --- a/MatrixAntiDmSpam/Program.cs +++ b/MatrixAntiDmSpam/Program.cs @@ -15,7 +15,7 @@ builder.Services.AddHostedService(); builder.Services.AddSingleton(); -MatrixHttpClient.LogRequests = false; +// MatrixHttpClient.LogRequests = false; var host = builder.Build(); host.Run(); \ No newline at end of file diff --git a/MatrixAntiDmSpam/appsettings.Development.json b/MatrixAntiDmSpam/appsettings.Development.json index fd582b4..4cfb975 100644 --- a/MatrixAntiDmSpam/appsettings.Development.json +++ b/MatrixAntiDmSpam/appsettings.Development.json @@ -34,8 +34,14 @@ } }, "AntiDmSpam": { + // Whether invites should be logged to a room. "LogRoom": "!GrLSwdAkdrvfMrRYKR:rory.gay", "LogInviteDataAsFile": true, + // Whether to report users and rooms when an invite is blocked. + "ReportBlockedInvites": true, + // WARNING: If you're a room moderator, this will cause your client to not receive events from ignored users! + "IgnoreBannedUsers": true, + // Policy lists to follow "PolicyLists": [ { "Name": "Community Moderation Effort", -- cgit 1.5.1