diff --git a/LibMatrix b/LibMatrix
-Subproject 1c30aec46b495f1da87c3a6adbda3e19e014b55
+Subproject 2fde2d5f961eabf3167280ba55786cdb6b38f2c
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<PolicyRoomReference> 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<PolicyEventReference> 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/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<StateEventResponse> 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>(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<UserPolicyRuleEventContent>();
+
+ 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>(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<Task>() // 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<string, PolicyRuleEventContent> AllPolicies { get; } = [];
- public List<Func<PolicyRuleEventContent, Task>> OnPolicyUpdated { get; } = [];
+public class PolicyStore(ILogger<PolicyStore> logger) {
+ public Dictionary<string, StateEventResponse> AllPolicies { get; } = [];
- public Task AddPoliciesAsync(IEnumerable<StateEventResponse> 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);
+ /// <summary>
+ /// Fired when any policy event is received
+ /// </summary>
+ public List<Func<StateEventResponse, Task>> OnPolicyReceived { get; } = [];
- foreach (var callback in OnPolicyUpdated) {
- await callback(policy);
+ /// <summary>
+ /// Fired when a new policy is added
+ /// </summary>
+ public List<Func<StateEventResponse, Task>> OnPolicyAdded { get; } = [];
+
+ /// <summary>
+ /// Fired when a policy is updated, without being removed.
+ /// </summary>
+ public List<Func<StateEventResponse, StateEventResponse, Task>> OnPolicyUpdated { get; } = [];
+
+ /// <summary>
+ /// Fired when a policy is removed.
+ /// </summary>
+ public List<Func<StateEventResponse, StateEventResponse, Task>> OnPolicyRemoved { get; } = [];
+
+#endregion
+
+#region Bulk policy events
+
+ /// <summary>
+ /// Fired when any policy event is received
+ /// </summary>
+ public List<Func<(List<StateEventResponse> NewPolicies,
+ List<(StateEventResponse Old, StateEventResponse New)> UpdatedPolicies,
+ List<(StateEventResponse Old, StateEventResponse New)> RemovedPolicies), Task>> OnPoliciesChanged { get; } = [];
+
+#endregion
+
+ public async Task AddPoliciesAsync(IEnumerable<StateEventResponse> events) {
+ var policyEvents = events
+ .Where(evt => evt.TypedContent is PolicyRuleEventContent)
+ .ToList();
+ List<StateEventResponse> 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/InviteHandler.cs b/MatrixAntiDmSpam.Core/RoomInviteHandler.cs
index 41f4011..41f4011 100644
--- a/MatrixAntiDmSpam.Core/InviteHandler.cs
+++ b/MatrixAntiDmSpam.Core/RoomInviteHandler.cs
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 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExceptionDispatchInfo_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fea51ca5e833244688d7ca912cfc70784d19c00_003F97_003Ffb30ee1f_003FExceptionDispatchInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHashtable_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fea51ca5e833244688d7ca912cfc70784d19c00_003Fd2_003F20632896_003FHashtable_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AList_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fea51ca5e833244688d7ca912cfc70784d19c00_003F65_003Fb77a719c_003FList_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
+ <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AList_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ff1b929573c264d7a81f261ae2f951019d19e00_003F2f_003F707c45aa_003FList_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AObjectExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8fd5e96d6574456095123be1ecfbdfa914200_003Fe2_003F3561a383_003FObjectExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceCollectionHostedServiceExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ff13297a632424a6abffea4dd75a36a75d128_003Ff9_003Fb35aae11_003FServiceCollectionHostedServiceExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStackFrameIterator_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fea51ca5e833244688d7ca912cfc70784d19c00_003F86_003F8b4aa64e_003FStackFrameIterator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
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<PolicyExecutor>();
builder.Services.AddSingleton<PolicyStore>();
-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",
|