From 41316bb445790e7df34f55b7e2b1cfeae04f2adc Mon Sep 17 00:00:00 2001 From: "Emma [it/its]@Rory&" Date: Thu, 23 Nov 2023 05:42:33 +0100 Subject: Moderation bot work --- AccountData/BotData.cs | 14 ++ Commands/BanMediaCommand.cs | 113 +++++++++ Commands/DbgAllRoomsArePolicyListsCommand.cs | 63 +++++ Commands/DbgDumpActivePoliciesCommand.cs | 43 ++++ Commands/DbgDumpAllStateTypesCommand.cs | 73 ++++++ Commands/JoinRoomCommand.cs | 54 ++++ Commands/JoinSpaceMembersCommand.cs | 75 ++++++ FirstRunTasks.cs | 84 +++++++ ModerationBot.cs | 275 +++++++++++++++++++++ ModerationBot.csproj | 33 +++ ModerationBotConfiguration.cs | 10 + PolicyEngine.cs | 269 ++++++++++++++++++++ PolicyList.cs | 17 ++ Program.cs | 28 +++ Properties/launchSettings.json | 26 ++ StateEventTypes/Policies/BasePolicy.cs | 52 ++++ .../Policies/Implementations/MediaPolicyFile.cs | 16 ++ .../Implementations/MediaPolicyHomeserver.cs | 11 + .../Implementations/MessagePolicyContainsText.cs | 11 + .../Policies/Implementations/UnknownPolicy.cs | 10 + appsettings.Development.json | 24 ++ appsettings.json | 9 + 22 files changed, 1310 insertions(+) create mode 100644 AccountData/BotData.cs create mode 100644 Commands/BanMediaCommand.cs create mode 100644 Commands/DbgAllRoomsArePolicyListsCommand.cs create mode 100644 Commands/DbgDumpActivePoliciesCommand.cs create mode 100644 Commands/DbgDumpAllStateTypesCommand.cs create mode 100644 Commands/JoinRoomCommand.cs create mode 100644 Commands/JoinSpaceMembersCommand.cs create mode 100644 FirstRunTasks.cs create mode 100644 ModerationBot.cs create mode 100644 ModerationBot.csproj create mode 100644 ModerationBotConfiguration.cs create mode 100644 PolicyEngine.cs create mode 100644 PolicyList.cs create mode 100644 Program.cs create mode 100644 Properties/launchSettings.json create mode 100644 StateEventTypes/Policies/BasePolicy.cs create mode 100644 StateEventTypes/Policies/Implementations/MediaPolicyFile.cs create mode 100644 StateEventTypes/Policies/Implementations/MediaPolicyHomeserver.cs create mode 100644 StateEventTypes/Policies/Implementations/MessagePolicyContainsText.cs create mode 100644 StateEventTypes/Policies/Implementations/UnknownPolicy.cs create mode 100644 appsettings.Development.json create mode 100644 appsettings.json diff --git a/AccountData/BotData.cs b/AccountData/BotData.cs new file mode 100644 index 0000000..df86589 --- /dev/null +++ b/AccountData/BotData.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace ModerationBot.AccountData; + +public class BotData { + [JsonPropertyName("control_room")] + public string ControlRoom { get; set; } = ""; + + [JsonPropertyName("log_room")] + public string? LogRoom { get; set; } = ""; + + [JsonPropertyName("default_policy_room")] + public string? DefaultPolicyRoom { get; set; } +} \ No newline at end of file diff --git a/Commands/BanMediaCommand.cs b/Commands/BanMediaCommand.cs new file mode 100644 index 0000000..21e0a94 --- /dev/null +++ b/Commands/BanMediaCommand.cs @@ -0,0 +1,113 @@ +using System.Buffers.Text; +using System.Security.Cryptography; +using ArcaneLibs.Extensions; +using LibMatrix; +using LibMatrix.EventTypes.Spec; +using LibMatrix.Helpers; +using LibMatrix.Services; +using LibMatrix.Utilities.Bot.Interfaces; +using ModerationBot.AccountData; +using ModerationBot.StateEventTypes; +using ModerationBot.StateEventTypes.Policies.Implementations; + +namespace ModerationBot.Commands; + +public class BanMediaCommand(IServiceProvider services, HomeserverProviderService hsProvider, HomeserverResolverService hsResolver, PolicyEngine engine) : ICommand { + public string Name { get; } = "banmedia"; + public string Description { get; } = "Create a policy banning a piece of media, must be used in reply to a message"; + + public async Task CanInvoke(CommandContext ctx) { + //check if user is admin in control room + var botData = await ctx.Homeserver.GetAccountDataAsync("gay.rory.moderation_bot_data"); + var controlRoom = ctx.Homeserver.GetRoom(botData.ControlRoom); + var isAdmin = (await controlRoom.GetPowerLevelsAsync())!.UserHasPermission(ctx.MessageEvent.Sender, "m.room.ban"); + if (!isAdmin) { + // await ctx.Reply("You do not have permission to use this command!"); + await ctx.Homeserver.GetRoom(botData.LogRoom!).SendMessageEventAsync( + new RoomMessageEventContent(body: $"User {ctx.MessageEvent.Sender} tried to use command {Name} but does not have permission!", messageType: "m.text")); + } + + return isAdmin; + } + + public async Task Invoke(CommandContext ctx) { + + var botData = await ctx.Homeserver.GetAccountDataAsync("gay.rory.moderation_bot_data"); + var policyRoom = ctx.Homeserver.GetRoom(botData.DefaultPolicyRoom ?? botData.ControlRoom); + var logRoom = ctx.Homeserver.GetRoom(botData.LogRoom ?? botData.ControlRoom); + + //check if reply + var messageContent = ctx.MessageEvent.TypedContent as RoomMessageEventContent; + if (messageContent?.RelatesTo is { InReplyTo: not null }) { + try { + await logRoom.SendMessageEventAsync( + new RoomMessageEventContent( + body: $"User {MessageFormatter.HtmlFormatMention(ctx.MessageEvent.Sender)} is trying to ban media {messageContent!.RelatesTo!.InReplyTo!.EventId}", + messageType: "m.text")); + + //get replied message + var repliedMessage = await ctx.Room.GetEventAsync(messageContent.RelatesTo!.InReplyTo!.EventId); + + //check if recommendation is in list + if (ctx.Args.Length < 2) { + await ctx.Room.SendMessageEventAsync(MessageFormatter.FormatError("You must specify a recommendation type and reason!")); + return; + } + + var recommendation = ctx.Args[0]; + + if (recommendation is not ("ban" or "kick" or "mute" or "redact" or "spoiler" or "warn" or "warn_admins")) { + await ctx.Room.SendMessageEventAsync( + MessageFormatter.FormatError( + $"Invalid recommendation type {recommendation}, must be `warn_admins`, `warn`, `spoiler`, `redact`, `mute`, `kick` or `ban`!")); + return; + } + + //hash file + var mxcUri = (repliedMessage.TypedContent as RoomMessageEventContent).Url!; + var resolvedUri = await hsResolver.ResolveMediaUri(mxcUri.Split('/')[2], mxcUri); + var hashAlgo = SHA3_256.Create(); + var uriHash = hashAlgo.ComputeHash(mxcUri.AsBytes().ToArray()); + byte[]? fileHash = null; + + try { + fileHash = await hashAlgo.ComputeHashAsync(await ctx.Homeserver.ClientHttpClient.GetStreamAsync(resolvedUri)); + } + catch (Exception ex) { + await logRoom.SendMessageEventAsync( + MessageFormatter.FormatException($"Error calculating file hash for {mxcUri} via {mxcUri.Split('/')[2]}, retrying via {ctx.Homeserver.BaseUrl}...", + ex)); + try { + resolvedUri = await hsResolver.ResolveMediaUri(ctx.Homeserver.BaseUrl, mxcUri); + fileHash = await hashAlgo.ComputeHashAsync(await ctx.Homeserver.ClientHttpClient.GetStreamAsync(resolvedUri)); + } + catch (Exception ex2) { + await ctx.Room.SendMessageEventAsync(MessageFormatter.FormatException("Error calculating file hash", ex2)); + await logRoom.SendMessageEventAsync( + MessageFormatter.FormatException($"Error calculating file hash via {ctx.Homeserver.BaseUrl}!", ex2)); + } + } + + MediaPolicyFile policy; + await policyRoom.SendStateEventAsync("gay.rory.moderation.rule.media", Guid.NewGuid().ToString(), policy = new MediaPolicyFile { + Entity = Convert.ToBase64String(uriHash), + FileHash = Convert.ToBase64String(fileHash), + Reason = string.Join(' ', ctx.Args[1..]), + Recommendation = recommendation, + }); + + await ctx.Room.SendMessageEventAsync(MessageFormatter.FormatSuccessJson("Media policy created", policy)); + await logRoom.SendMessageEventAsync(MessageFormatter.FormatSuccessJson("Media policy created", policy)); + } + catch (Exception e) { + await logRoom.SendMessageEventAsync(MessageFormatter.FormatException("Error creating policy", e)); + await ctx.Room.SendMessageEventAsync(MessageFormatter.FormatException("Error creating policy", e)); + await using var stream = new MemoryStream(e.ToString().AsBytes().ToArray()); + await logRoom.SendFileAsync("error.log.cs", stream); + } + } + else { + await ctx.Room.SendMessageEventAsync(MessageFormatter.FormatError("This command must be used in reply to a message!")); + } + } +} diff --git a/Commands/DbgAllRoomsArePolicyListsCommand.cs b/Commands/DbgAllRoomsArePolicyListsCommand.cs new file mode 100644 index 0000000..09d3caf --- /dev/null +++ b/Commands/DbgAllRoomsArePolicyListsCommand.cs @@ -0,0 +1,63 @@ +using System.Buffers.Text; +using System.Security.Cryptography; +using ArcaneLibs.Extensions; +using LibMatrix; +using LibMatrix.EventTypes.Spec; +using LibMatrix.Helpers; +using LibMatrix.RoomTypes; +using LibMatrix.Services; +using LibMatrix.Utilities.Bot.Interfaces; +using ModerationBot.AccountData; +using ModerationBot.StateEventTypes; + +namespace ModerationBot.Commands; + +public class DbgAllRoomsArePolicyListsCommand + (IServiceProvider services, HomeserverProviderService hsProvider, HomeserverResolverService hsResolver, PolicyEngine engine) : ICommand { + public string Name { get; } = "dbg-allroomsarepolicy"; + public string Description { get; } = "[Debug] mark all rooms as trusted policy rooms"; + private GenericRoom logRoom { get; set; } + + public async Task CanInvoke(CommandContext ctx) { +#if !DEBUG + return false; +#endif + + //check if user is admin in control room + var botData = await ctx.Homeserver.GetAccountDataAsync("gay.rory.moderation_bot_data"); + var controlRoom = ctx.Homeserver.GetRoom(botData.ControlRoom); + var isAdmin = (await controlRoom.GetPowerLevelsAsync())!.UserHasPermission(ctx.MessageEvent.Sender, "m.room.ban"); + if (!isAdmin) { + // await ctx.Reply("You do not have permission to use this command!"); + await ctx.Homeserver.GetRoom(botData.LogRoom!).SendMessageEventAsync( + new RoomMessageEventContent(body: $"User {ctx.MessageEvent.Sender} tried to use command {Name} but does not have permission!", messageType: "m.text")); + } + + return isAdmin; + } + + public async Task Invoke(CommandContext ctx) { + var botData = await ctx.Homeserver.GetAccountDataAsync("gay.rory.moderation_bot_data"); + logRoom = ctx.Homeserver.GetRoom(botData.LogRoom ?? botData.ControlRoom); + + var joinedRooms = await ctx.Homeserver.GetJoinedRooms(); + + await ctx.Homeserver.SetAccountDataAsync("gay.rory.moderation_bot.policy_lists", joinedRooms.ToDictionary(x => x.RoomId, x => new PolicyList() { + Trusted = true + })); + + await engine.ReloadActivePolicyLists(); + } + + private async Task JoinRoom(GenericRoom memberRoom, string reason, List servers) { + try { + await memberRoom.JoinAsync(servers.ToArray(), reason); + await logRoom.SendMessageEventAsync(MessageFormatter.FormatSuccess($"Joined room {memberRoom.RoomId}")); + } + catch (Exception e) { + await logRoom.SendMessageEventAsync(MessageFormatter.FormatException($"Failed to join {memberRoom.RoomId}", e)); + } + + return true; + } +} \ No newline at end of file diff --git a/Commands/DbgDumpActivePoliciesCommand.cs b/Commands/DbgDumpActivePoliciesCommand.cs new file mode 100644 index 0000000..395c87c --- /dev/null +++ b/Commands/DbgDumpActivePoliciesCommand.cs @@ -0,0 +1,43 @@ +using System.Buffers.Text; +using System.Security.Cryptography; +using ArcaneLibs.Extensions; +using LibMatrix; +using LibMatrix.EventTypes.Spec; +using LibMatrix.Helpers; +using LibMatrix.RoomTypes; +using LibMatrix.Services; +using LibMatrix.Utilities.Bot.Interfaces; +using ModerationBot.AccountData; +using ModerationBot.StateEventTypes; + +namespace ModerationBot.Commands; + +public class DbgDumpActivePoliciesCommand + (IServiceProvider services, HomeserverProviderService hsProvider, HomeserverResolverService hsResolver, PolicyEngine engine) : ICommand { + public string Name { get; } = "dbg-dumppolicies"; + public string Description { get; } = "[Debug] Dump all active policies"; + private GenericRoom logRoom { get; set; } + + public async Task CanInvoke(CommandContext ctx) { +#if !DEBUG + return false; +#endif + + //check if user is admin in control room + var botData = await ctx.Homeserver.GetAccountDataAsync("gay.rory.moderation_bot_data"); + var controlRoom = ctx.Homeserver.GetRoom(botData.ControlRoom); + var isAdmin = (await controlRoom.GetPowerLevelsAsync())!.UserHasPermission(ctx.MessageEvent.Sender, "m.room.ban"); + if (!isAdmin) { + // await ctx.Reply("You do not have permission to use this command!"); + await ctx.Homeserver.GetRoom(botData.LogRoom!).SendMessageEventAsync( + new RoomMessageEventContent(body: $"User {ctx.MessageEvent.Sender} tried to use command {Name} but does not have permission!", messageType: "m.text")); + } + + return isAdmin; + } + + public async Task Invoke(CommandContext ctx) { + await ctx.Room.SendFileAsync("all.json", new MemoryStream(engine.ActivePolicies.ToJson().AsBytes().ToArray()), contentType: "application/json"); + await ctx.Room.SendFileAsync("by-type.json", new MemoryStream(engine.ActivePoliciesByType.ToJson().AsBytes().ToArray()), contentType: "application/json"); + } +} \ No newline at end of file diff --git a/Commands/DbgDumpAllStateTypesCommand.cs b/Commands/DbgDumpAllStateTypesCommand.cs new file mode 100644 index 0000000..e9a645e --- /dev/null +++ b/Commands/DbgDumpAllStateTypesCommand.cs @@ -0,0 +1,73 @@ +using System.Buffers.Text; +using System.Security.Cryptography; +using ArcaneLibs.Extensions; +using LibMatrix; +using LibMatrix.EventTypes.Spec; +using LibMatrix.Helpers; +using LibMatrix.RoomTypes; +using LibMatrix.Services; +using LibMatrix.Utilities.Bot.Interfaces; +using ModerationBot.AccountData; +using ModerationBot.StateEventTypes; + +namespace ModerationBot.Commands; + +public class DbgDumpAllStateTypesCommand + (IServiceProvider services, HomeserverProviderService hsProvider, HomeserverResolverService hsResolver, PolicyEngine engine) : ICommand { + public string Name { get; } = "dbg-dumpstatetypes"; + public string Description { get; } = "[Debug] Dump all state types we can find"; + private GenericRoom logRoom { get; set; } + + public async Task CanInvoke(CommandContext ctx) { +#if !DEBUG + return false; +#endif + + //check if user is admin in control room + var botData = await ctx.Homeserver.GetAccountDataAsync("gay.rory.moderation_bot_data"); + var controlRoom = ctx.Homeserver.GetRoom(botData.ControlRoom); + var isAdmin = (await controlRoom.GetPowerLevelsAsync())!.UserHasPermission(ctx.MessageEvent.Sender, "m.room.ban"); + if (!isAdmin) { + // await ctx.Reply("You do not have permission to use this command!"); + await ctx.Homeserver.GetRoom(botData.LogRoom!).SendMessageEventAsync( + new RoomMessageEventContent(body: $"User {ctx.MessageEvent.Sender} tried to use command {Name} but does not have permission!", messageType: "m.text")); + } + + return isAdmin; + } + + public async Task Invoke(CommandContext ctx) { + var botData = await ctx.Homeserver.GetAccountDataAsync("gay.rory.moderation_bot_data"); + logRoom = ctx.Homeserver.GetRoom(botData.LogRoom ?? botData.ControlRoom); + + + var joinedRooms = await ctx.Homeserver.GetJoinedRooms(); + + var tasks = joinedRooms.Select(GetStateTypes).ToAsyncEnumerable(); + await foreach (var (room, (raw, html)) in tasks) { + await ctx.Room.SendMessageEventAsync(new RoomMessageEventContent("m.text") { + Body = $"States for {room.RoomId}:\n{raw}", + FormattedBody = $"States for {room.RoomId}:\n{html}", + Format = "org.matrix.custom.html" + }); + } + } + + private async Task<(GenericRoom room, (string raw, string html))> GetStateTypes(GenericRoom memberRoom) { + var states = await memberRoom.GetFullStateAsListAsync(); + + return (memberRoom, SummariseStateTypeCounts(states)); + } + + private static (string Raw, string Html) SummariseStateTypeCounts(IList states) { + string raw = "Count | State type | Mapped type", html = ""; + var groupedStates = states.GroupBy(x => x.Type).ToDictionary(x => x.Key, x => x.ToList()).OrderByDescending(x => x.Value.Count); + foreach (var (type, stateGroup) in groupedStates) { + raw += $"{stateGroup.Count} | {type} | {stateGroup[0].GetType.Name}"; + html += $""; + } + + html += "
CountState typeMapped type
{stateGroup.Count}{type}{stateGroup[0].GetType.Name}
"; + return (raw, html); + } +} \ No newline at end of file diff --git a/Commands/JoinRoomCommand.cs b/Commands/JoinRoomCommand.cs new file mode 100644 index 0000000..19a2c54 --- /dev/null +++ b/Commands/JoinRoomCommand.cs @@ -0,0 +1,54 @@ +using System.Buffers.Text; +using System.Security.Cryptography; +using ArcaneLibs.Extensions; +using LibMatrix; +using LibMatrix.EventTypes.Spec; +using LibMatrix.Helpers; +using LibMatrix.Services; +using LibMatrix.Utilities.Bot.Interfaces; +using ModerationBot.AccountData; +using ModerationBot.StateEventTypes; + +namespace ModerationBot.Commands; + +public class JoinRoomCommand(IServiceProvider services, HomeserverProviderService hsProvider, HomeserverResolverService hsResolver, PolicyEngine engine) : ICommand { + public string Name { get; } = "join"; + public string Description { get; } = "Join arbitrary rooms"; + + public async Task CanInvoke(CommandContext ctx) { + //check if user is admin in control room + var botData = await ctx.Homeserver.GetAccountDataAsync("gay.rory.moderation_bot_data"); + var controlRoom = ctx.Homeserver.GetRoom(botData.ControlRoom); + var isAdmin = (await controlRoom.GetPowerLevelsAsync())!.UserHasPermission(ctx.MessageEvent.Sender, "m.room.ban"); + if (!isAdmin) { + // await ctx.Reply("You do not have permission to use this command!"); + await ctx.Homeserver.GetRoom(botData.LogRoom!).SendMessageEventAsync( + new RoomMessageEventContent(body: $"User {ctx.MessageEvent.Sender} tried to use command {Name} but does not have permission!", messageType: "m.text")); + } + + return isAdmin; + } + + public async Task Invoke(CommandContext ctx) { + + var botData = await ctx.Homeserver.GetAccountDataAsync("gay.rory.moderation_bot_data"); + var policyRoom = ctx.Homeserver.GetRoom(botData.DefaultPolicyRoom ?? botData.ControlRoom); + var logRoom = ctx.Homeserver.GetRoom(botData.LogRoom ?? botData.ControlRoom); + + await logRoom.SendMessageEventAsync(MessageFormatter.FormatSuccess($"Joining room {ctx.Args[0]} with reason: {string.Join(' ', ctx.Args[1..])}")); + var roomId = ctx.Args[0]; + var servers = new List() {ctx.Homeserver.ServerName}; + if (roomId.StartsWith('[')) { + + } + + if (roomId.StartsWith('#')) { + var res = await ctx.Homeserver.ResolveRoomAliasAsync(roomId); + roomId = res.RoomId; + servers.AddRange(servers); + } + + await ctx.Homeserver.JoinRoomAsync(roomId, servers, string.Join(' ', ctx.Args[1..])); + await logRoom.SendMessageEventAsync(MessageFormatter.FormatSuccess($"Resolved room {ctx.Args[0]} to {roomId} with servers: {string.Join(", ", servers)}")); + } +} diff --git a/Commands/JoinSpaceMembersCommand.cs b/Commands/JoinSpaceMembersCommand.cs new file mode 100644 index 0000000..c3b7d12 --- /dev/null +++ b/Commands/JoinSpaceMembersCommand.cs @@ -0,0 +1,75 @@ +using System.Buffers.Text; +using System.Security.Cryptography; +using ArcaneLibs.Extensions; +using LibMatrix; +using LibMatrix.EventTypes.Spec; +using LibMatrix.Helpers; +using LibMatrix.RoomTypes; +using LibMatrix.Services; +using LibMatrix.Utilities.Bot.Interfaces; +using ModerationBot.AccountData; +using ModerationBot.StateEventTypes; + +namespace ModerationBot.Commands; + +public class JoinSpaceMembersCommand(IServiceProvider services, HomeserverProviderService hsProvider, HomeserverResolverService hsResolver, PolicyEngine engine) : ICommand { + public string Name { get; } = "joinspacemembers"; + public string Description { get; } = "Join all rooms in space"; + private GenericRoom logRoom { get; set; } + + public async Task CanInvoke(CommandContext ctx) { + //check if user is admin in control room + var botData = await ctx.Homeserver.GetAccountDataAsync("gay.rory.moderation_bot_data"); + var controlRoom = ctx.Homeserver.GetRoom(botData.ControlRoom); + var isAdmin = (await controlRoom.GetPowerLevelsAsync())!.UserHasPermission(ctx.MessageEvent.Sender, "m.room.ban"); + if (!isAdmin) { + // await ctx.Reply("You do not have permission to use this command!"); + await ctx.Homeserver.GetRoom(botData.LogRoom!).SendMessageEventAsync( + new RoomMessageEventContent(body: $"User {ctx.MessageEvent.Sender} tried to use command {Name} but does not have permission!", messageType: "m.text")); + } + + return isAdmin; + } + + public async Task Invoke(CommandContext ctx) { + var botData = await ctx.Homeserver.GetAccountDataAsync("gay.rory.moderation_bot_data"); + logRoom = ctx.Homeserver.GetRoom(botData.LogRoom ?? botData.ControlRoom); + + await logRoom.SendMessageEventAsync(MessageFormatter.FormatSuccess($"Joining space children of {ctx.Args[0]} with reason: {string.Join(' ', ctx.Args[1..])}")); + var roomId = ctx.Args[0]; + var servers = new List() {ctx.Homeserver.ServerName}; + if (roomId.StartsWith('[')) { + + } + + if (roomId.StartsWith('#')) { + var res = await ctx.Homeserver.ResolveRoomAliasAsync(roomId); + roomId = res.RoomId; + servers.AddRange(servers); + } + + var room = ctx.Homeserver.GetRoom(roomId); + var tasks = new List>(); + await foreach (var memberRoom in room.AsSpace.GetChildrenAsync()) { + servers.Add(room.RoomId.Split(':', 2)[1]); + servers = servers.Distinct().ToList(); + tasks.Add(JoinRoom(memberRoom, string.Join(' ', ctx.Args[1..]), servers)); + } + + await foreach (var b in tasks.ToAsyncEnumerable()) { + await Task.Delay(50); + } + } + + private async Task JoinRoom(GenericRoom memberRoom, string reason, List servers) { + try { + await memberRoom.JoinAsync(servers.ToArray(), reason); + await logRoom.SendMessageEventAsync(MessageFormatter.FormatSuccess($"Joined room {memberRoom.RoomId}")); + } + catch (Exception e) { + await logRoom.SendMessageEventAsync(MessageFormatter.FormatException($"Failed to join {memberRoom.RoomId}", e)); + } + + return true; + } +} diff --git a/FirstRunTasks.cs b/FirstRunTasks.cs new file mode 100644 index 0000000..ebbdc81 --- /dev/null +++ b/FirstRunTasks.cs @@ -0,0 +1,84 @@ +using LibMatrix; +using LibMatrix.EventTypes.Spec.State; +using LibMatrix.Homeservers; +using LibMatrix.Responses; +using ModerationBot.AccountData; + +namespace ModerationBot; + +public class FirstRunTasks { + public static async Task ConstructBotData(AuthenticatedHomeserverGeneric hs, ModerationBotConfiguration configuration, BotData? botdata) { + botdata ??= new BotData(); + var creationContent = CreateRoomRequest.CreatePrivate(hs, name: "Rory&::ModerationBot - Control room", roomAliasName: "moderation-bot-control-room"); + creationContent.Invite = configuration.Admins; + creationContent.CreationContent["type"] = "gay.rory.moderation_bot.control_room"; + + if (botdata.ControlRoom is null) + try { + botdata.ControlRoom = (await hs.CreateRoom(creationContent)).RoomId; + } + catch (Exception e) { + if (e is not MatrixException { ErrorCode: "M_ROOM_IN_USE" }) { + Console.WriteLine(e); + throw; + } + + creationContent.RoomAliasName += $"-{Guid.NewGuid()}"; + botdata.ControlRoom = (await hs.CreateRoom(creationContent)).RoomId; + } + //set access rules to allow joining via control room + // creationContent.InitialState.Add(new StateEvent { + // Type = "m.room.join_rules", + // StateKey = "", + // TypedContent = new RoomJoinRulesEventContent { + // JoinRule = "knock_restricted", + // Allow = new() { + // new RoomJoinRulesEventContent.AllowEntry { + // Type = "m.room_membership", + // RoomId = botdata.ControlRoom + // } + // } + // } + // }); + + creationContent.Name = "Rory&::ModerationBot - Log room"; + creationContent.RoomAliasName = "moderation-bot-log-room"; + creationContent.CreationContent["type"] = "gay.rory.moderation_bot.log_room"; + + if (botdata.LogRoom is null) + try { + botdata.LogRoom = (await hs.CreateRoom(creationContent)).RoomId; + } + catch (Exception e) { + if (e is not MatrixException { ErrorCode: "M_ROOM_IN_USE" }) { + Console.WriteLine(e); + throw; + } + + creationContent.RoomAliasName += $"-{Guid.NewGuid()}"; + botdata.LogRoom = (await hs.CreateRoom(creationContent)).RoomId; + } + + creationContent.Name = "Rory&::ModerationBot - Policy room"; + creationContent.RoomAliasName = "moderation-bot-policy-room"; + creationContent.CreationContent["type"] = "gay.rory.moderation_bot.policy_room"; + + if (botdata.DefaultPolicyRoom is null) + try { + botdata.DefaultPolicyRoom = (await hs.CreateRoom(creationContent)).RoomId; + } + catch (Exception e) { + if (e is not MatrixException { ErrorCode: "M_ROOM_IN_USE" }) { + Console.WriteLine(e); + throw; + } + + creationContent.RoomAliasName += $"-{Guid.NewGuid()}"; + botdata.DefaultPolicyRoom = (await hs.CreateRoom(creationContent)).RoomId; + } + + await hs.SetAccountDataAsync("gay.rory.moderation_bot_data", botdata); + + return botdata; + } +} \ No newline at end of file diff --git a/ModerationBot.cs b/ModerationBot.cs new file mode 100644 index 0000000..79b05bf --- /dev/null +++ b/ModerationBot.cs @@ -0,0 +1,275 @@ +using System.Security.Cryptography; +using System.Text.RegularExpressions; +using ArcaneLibs.Extensions; +using LibMatrix; +using LibMatrix.EventTypes.Spec; +using LibMatrix.EventTypes.Spec.State; +using LibMatrix.Helpers; +using LibMatrix.Homeservers; +using LibMatrix.Responses; +using LibMatrix.RoomTypes; +using LibMatrix.Services; +using LibMatrix.Utilities.Bot.Interfaces; +using ModerationBot.AccountData; +using ModerationBot.StateEventTypes; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ModerationBot.StateEventTypes.Policies; + +namespace ModerationBot; + +public class ModerationBot(AuthenticatedHomeserverGeneric hs, ILogger logger, ModerationBotConfiguration configuration, + HomeserverResolverService hsResolver, PolicyEngine engine) : IHostedService { + private readonly IEnumerable _commands; + + private Task _listenerTask; + + // private GenericRoom _policyRoom; + private GenericRoom _logRoom; + private GenericRoom _controlRoom; + + /// Triggered when the application host is ready to start the service. + /// Indicates that the start process has been aborted. + public async Task StartAsync(CancellationToken cancellationToken) { + _listenerTask = Run(cancellationToken); + logger.LogInformation("Bot started!"); + } + + private async Task Run(CancellationToken cancellationToken) { + if (Directory.Exists("bot_data/cache")) + Directory.GetFiles("bot_data/cache").ToList().ForEach(File.Delete); + + BotData botData; + + try { + logger.LogInformation("Fetching bot account data..."); + botData = await hs.GetAccountDataAsync("gay.rory.moderation_bot_data"); + logger.LogInformation("Got bot account data..."); + } + catch (Exception e) { + logger.LogInformation("Could not fetch bot account data... {}", e.Message); + if (e is not MatrixException { ErrorCode: "M_NOT_FOUND" }) { + logger.LogError("{}", e.ToString()); + throw; + } + + botData = null; + } + + botData = await FirstRunTasks.ConstructBotData(hs, configuration, botData); + + // _policyRoom = hs.GetRoom(botData.PolicyRoom ?? botData.ControlRoom); + _logRoom = hs.GetRoom(botData.LogRoom ?? botData.ControlRoom); + _controlRoom = hs.GetRoom(botData.ControlRoom); + foreach (var configurationAdmin in configuration.Admins) { + var pls = await _controlRoom.GetPowerLevelsAsync(); + pls.SetUserPowerLevel(configurationAdmin, pls.GetUserPowerLevel(hs.UserId)); + await _controlRoom.SendStateEventAsync(RoomPowerLevelEventContent.EventId, pls); + } + var syncHelper = new SyncHelper(hs); + + List admins = new(); + +#pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed + Task.Run(async () => { + while (!cancellationToken.IsCancellationRequested) { + var controlRoomMembers = _controlRoom.GetMembersAsync(); + await foreach (var member in controlRoomMembers) { + if ((member.TypedContent as RoomMemberEventContent)? + .Membership == "join") admins.Add(member.StateKey); + } + + await Task.Delay(TimeSpan.FromMinutes(5), cancellationToken); + } + }, cancellationToken); +#pragma warning restore CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed + + syncHelper.InviteReceivedHandlers.Add(async Task (args) => { + var inviteEvent = + args.Value.InviteState.Events.FirstOrDefault(x => + x.Type == "m.room.member" && x.StateKey == hs.UserId); + logger.LogInformation("Got invite to {RoomId} by {Sender} with reason: {Reason}", args.Key, inviteEvent!.Sender, + (inviteEvent.TypedContent as RoomMemberEventContent)!.Reason); + await _logRoom.SendMessageEventAsync(MessageFormatter.FormatSuccess($"Bot invited to {MessageFormatter.HtmlFormatMention(args.Key)} by {MessageFormatter.HtmlFormatMention(inviteEvent.Sender)}")); + if (admins.Contains(inviteEvent.Sender)) { + try { + await _logRoom.SendMessageEventAsync(MessageFormatter.FormatSuccess($"Joining {MessageFormatter.HtmlFormatMention(args.Key)}...")); + var senderProfile = await hs.GetProfileAsync(inviteEvent.Sender); + await hs.GetRoom(args.Key).JoinAsync(reason: $"I was invited by {senderProfile.DisplayName ?? inviteEvent.Sender}!"); + } + catch (Exception e) { + logger.LogError("{}", e.ToString()); + await _logRoom.SendMessageEventAsync(MessageFormatter.FormatException("Could not join room", e)); + await hs.GetRoom(args.Key).LeaveAsync(reason: "I was unable to join the room: " + e); + } + } + }); + + syncHelper.TimelineEventHandlers.Add(async @event => { + var room = hs.GetRoom(@event.RoomId); + try { + logger.LogInformation( + "Got timeline event in {}: {}", @event.RoomId, @event.ToJson(indent: true, ignoreNull: true)); + + if (@event != null && ( + @event.GetType.IsAssignableTo(typeof(BasePolicy)) + || @event.GetType.IsAssignableTo(typeof(PolicyRuleEventContent)) + )) + await engine.ReloadActivePolicyListById(@event.RoomId); + + var rules = await engine.GetMatchingPolicies(@event); + foreach (var matchedRule in rules) { + await _logRoom.SendMessageEventAsync(MessageFormatter.FormatSuccessJson( + $"{MessageFormatter.HtmlFormatMessageLink(eventId: @event.EventId, roomId: room.RoomId, displayName: "Event")} matched {MessageFormatter.HtmlFormatMessageLink(eventId: @matchedRule.OriginalEvent.EventId, roomId: matchedRule.PolicyList.Room.RoomId, displayName: "rule")}", @matchedRule.OriginalEvent.RawContent)); + } + + if (configuration.DemoMode) { + // foreach (var matchedRule in rules) { + // await room.SendMessageEventAsync(MessageFormatter.FormatSuccessJson( + // $"{MessageFormatter.HtmlFormatMessageLink(eventId: @event.EventId, roomId: room.RoomId, displayName: "Event")} matched {MessageFormatter.HtmlFormatMessageLink(eventId: @matchedRule.EventId, roomId: matchedRule.RoomId, displayName: "rule")}", @matchedRule.RawContent)); + // } + return; + } +// +// if (@event is { Type: "m.room.message", TypedContent: RoomMessageEventContent message }) { +// if (message is { MessageType: "m.image" }) { +// //check media +// // var matchedPolicy = await CheckMedia(@event); +// var matchedPolicy = rules.FirstOrDefault(); +// if (matchedPolicy is null) return; +// var matchedpolicyData = matchedPolicy.TypedContent as MediaPolicyEventContent; +// await _logRoom.SendMessageEventAsync( +// new RoomMessageEventContent( +// body: +// $"User {MessageFormatter.HtmlFormatMention(@event.Sender)} posted an image in {MessageFormatter.HtmlFormatMention(room.RoomId)} that matched rule {matchedPolicy.StateKey}, applying action {matchedpolicyData.Recommendation}, as described in rule: {matchedPolicy.RawContent!.ToJson(ignoreNull: true)}", +// messageType: "m.text") { +// Format = "org.matrix.custom.html", +// FormattedBody = +// $"User {MessageFormatter.HtmlFormatMention(@event.Sender)} posted an image in {MessageFormatter.HtmlFormatMention(room.RoomId)} that matched rule {matchedPolicy.StateKey}, applying action {matchedpolicyData.Recommendation}, as described in rule:
{matchedPolicy.RawContent!.ToJson(ignoreNull: true)}
" +// }); +// switch (matchedpolicyData.Recommendation) { +// case "warn_admins": { +// await _controlRoom.SendMessageEventAsync( +// new RoomMessageEventContent( +// body: $"{string.Join(' ', admins)}\nUser {MessageFormatter.HtmlFormatMention(@event.Sender)} posted a banned image {message.Url}", +// messageType: "m.text") { +// Format = "org.matrix.custom.html", +// FormattedBody = $"{string.Join(' ', admins.Select(u => MessageFormatter.HtmlFormatMention(u)))}\n" + +// $"User {MessageFormatter.HtmlFormatMention(@event.Sender)} posted a banned image {message.Url}" +// }); +// break; +// } +// case "warn": { +// await room.SendMessageEventAsync( +// new RoomMessageEventContent( +// body: $"Please be careful when posting this image: {matchedpolicyData.Reason ?? "No reason specified"}", +// messageType: "m.text") { +// Format = "org.matrix.custom.html", +// FormattedBody = +// $"Please be careful when posting this image: {matchedpolicyData.Reason ?? "No reason specified"}" +// }); +// break; +// } +// case "redact": { +// await room.RedactEventAsync(@event.EventId, matchedpolicyData.Reason ?? "No reason specified"); +// break; +// } +// case "spoiler": { +// //
+// // @emma:rory.gay
+// // +// // +// // CN +// // : +// // test
+// // +// // +// // +// //
+// await room.SendMessageEventAsync( +// new RoomMessageEventContent( +// body: +// $"Please be careful when posting this image: {matchedpolicyData.Reason}, I have spoilered it for you:", +// messageType: "m.text") { +// Format = "org.matrix.custom.html", +// FormattedBody = +// $"Please be careful when posting this image: {matchedpolicyData.Reason}, I have spoilered it for you:" +// }); +// var imageUrl = message.Url; +// await room.SendMessageEventAsync( +// new RoomMessageEventContent(body: $"CN: {imageUrl}", +// messageType: "m.text") { +// Format = "org.matrix.custom.html", +// FormattedBody = $""" +//
+// +// CN +// : +// {matchedpolicyData.Reason}
+// +// +// +// +// +//
+// """ +// }); +// await room.RedactEventAsync(@event.EventId, "Automatically spoilered: " + matchedpolicyData.Reason); +// break; +// } +// case "mute": { +// await room.RedactEventAsync(@event.EventId, matchedpolicyData.Reason); +// //change powerlevel to -1 +// var currentPls = await room.GetPowerLevelsAsync(); +// if (currentPls is null) { +// logger.LogWarning("Unable to get power levels for {room}", room.RoomId); +// await _logRoom.SendMessageEventAsync( +// MessageFormatter.FormatError($"Unable to get power levels for {MessageFormatter.HtmlFormatMention(room.RoomId)}")); +// return; +// } +// +// currentPls.Users ??= new(); +// currentPls.Users[@event.Sender] = -1; +// await room.SendStateEventAsync("m.room.power_levels", currentPls); +// break; +// } +// case "kick": { +// await room.RedactEventAsync(@event.EventId, matchedpolicyData.Reason); +// await room.KickAsync(@event.Sender, matchedpolicyData.Reason); +// break; +// } +// case "ban": { +// await room.RedactEventAsync(@event.EventId, matchedpolicyData.Reason); +// await room.BanAsync(@event.Sender, matchedpolicyData.Reason); +// break; +// } +// default: { +// throw new ArgumentOutOfRangeException("recommendation", +// $"Unknown response type {matchedpolicyData.Recommendation}!"); +// } +// } +// } +// } + } + catch (Exception e) { + logger.LogError("{}", e.ToString()); + await _controlRoom.SendMessageEventAsync( + MessageFormatter.FormatException($"Unable to process event in {MessageFormatter.HtmlFormatMention(room.RoomId)}", e)); + await _logRoom.SendMessageEventAsync( + MessageFormatter.FormatException($"Unable to process event in {MessageFormatter.HtmlFormatMention(room.RoomId)}", e)); + await using var stream = new MemoryStream(e.ToString().AsBytes().ToArray()); + await _controlRoom.SendFileAsync("error.log.cs", stream); + await _logRoom.SendFileAsync("error.log.cs", stream); + } + }); + await engine.ReloadActivePolicyLists(); + await syncHelper.RunSyncLoopAsync(); + } + + /// Triggered when the application host is performing a graceful shutdown. + /// Indicates that the shutdown process should no longer be graceful. + public async Task StopAsync(CancellationToken cancellationToken) { + logger.LogInformation("Shutting down bot!"); + } + +} \ No newline at end of file diff --git a/ModerationBot.csproj b/ModerationBot.csproj new file mode 100644 index 0000000..5c8f8ff --- /dev/null +++ b/ModerationBot.csproj @@ -0,0 +1,33 @@ + + + + Exe + net8.0 + preview + enable + enable + false + true + + + + + + + + + + + + + + + + + + + + Always + + + diff --git a/ModerationBotConfiguration.cs b/ModerationBotConfiguration.cs new file mode 100644 index 0000000..415b581 --- /dev/null +++ b/ModerationBotConfiguration.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.Configuration; + +namespace ModerationBot; + +public class ModerationBotConfiguration { + public ModerationBotConfiguration(IConfiguration config) => config.GetRequiredSection("ModerationBot").Bind(this); + + public List Admins { get; set; } = new(); + public bool DemoMode { get; set; } = false; +} diff --git a/PolicyEngine.cs b/PolicyEngine.cs new file mode 100644 index 0000000..5311637 --- /dev/null +++ b/PolicyEngine.cs @@ -0,0 +1,269 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.RegularExpressions; +using ArcaneLibs.Extensions; +using LibMatrix; +using LibMatrix.EventTypes.Spec; +using LibMatrix.EventTypes.Spec.State; +using LibMatrix.Helpers; +using LibMatrix.Homeservers; +using LibMatrix.Interfaces; +using LibMatrix.RoomTypes; +using LibMatrix.Services; +using ModerationBot.AccountData; +using ModerationBot.StateEventTypes; +using Microsoft.Extensions.Logging; +using ModerationBot.StateEventTypes.Policies; +using ModerationBot.StateEventTypes.Policies.Implementations; + +namespace ModerationBot; + +public class PolicyEngine(AuthenticatedHomeserverGeneric hs, ILogger logger, ModerationBotConfiguration configuration, HomeserverResolverService hsResolver) { + private Dictionary PolicyListAccountData { get; set; } = new(); + public List ActivePolicyLists { get; set; } = new(); + public List ActivePolicies { get; set; } = new(); + public Dictionary> ActivePoliciesByType { get; set; } = new(); + private GenericRoom? _logRoom; + private GenericRoom? _controlRoom; + + public async Task ReloadActivePolicyLists() { + var sw = Stopwatch.StartNew(); + + var botData = await hs.GetAccountDataAsync("gay.rory.moderation_bot_data"); + _logRoom ??= hs.GetRoom(botData.LogRoom ?? botData.ControlRoom); + _controlRoom ??= hs.GetRoom(botData.ControlRoom); + + await _controlRoom?.SendMessageEventAsync(MessageFormatter.FormatSuccess("Reloading policy lists!"))!; + await _logRoom?.SendMessageEventAsync(MessageFormatter.FormatSuccess("Reloading policy lists!"))!; + + var progressMessage = await _logRoom?.SendMessageEventAsync(MessageFormatter.FormatSuccess("0/? policy lists loaded"))!; + + var policyLists = new List(); + try { + PolicyListAccountData = await hs.GetAccountDataAsync>("gay.rory.moderation_bot.policy_lists"); + } + catch (MatrixException e) { + if (e is not { ErrorCode: "M_NOT_FOUND" }) throw; + } + + if (!PolicyListAccountData.ContainsKey(botData.DefaultPolicyRoom)) { + PolicyListAccountData.Add(botData.DefaultPolicyRoom, new PolicyList() { + Trusted = true + }); + await hs.SetAccountDataAsync("gay.rory.moderation_bot.policy_lists", PolicyListAccountData); + } + + var loadTasks = new List>(); + foreach (var (roomId, policyList) in PolicyListAccountData) { + var room = hs.GetRoom(roomId); + loadTasks.Add(LoadPolicyListAsync(room, policyList)); + } + + await foreach (var policyList in loadTasks.ToAsyncEnumerable()) { + policyLists.Add(policyList); + + if (policyList.Policies.Count >= 256 || policyLists.Count == PolicyListAccountData.Count) { + var progressMsgContent = MessageFormatter.FormatSuccess($"{policyLists.Count}/{PolicyListAccountData.Count} policy lists loaded, " + + $"{policyLists.Sum(x => x.Policies.Count)} policies total, {sw.Elapsed} elapsed.") + .SetReplaceRelation(progressMessage.EventId); + + _logRoom?.SendMessageEventAsync(progressMsgContent); + } + } + + // Console.WriteLine($"Reloaded policy list data in {sw.Elapsed}"); + // await _logRoom.SendMessageEventAsync(MessageFormatter.FormatSuccess($"Done fetching {policyLists.Count} policy lists in {sw.Elapsed}!")); + + ActivePolicyLists = policyLists; + ActivePolicies = await GetActivePolicies(); + } + + private async Task LoadPolicyListAsync(GenericRoom room, PolicyList policyList) { + policyList.Room = room; + policyList.Policies.Clear(); + + var stateEvents = room.GetFullStateAsync(); + await foreach (var stateEvent in stateEvents) { + if (stateEvent != null && ( + stateEvent.GetType.IsAssignableTo(typeof(BasePolicy)) + || stateEvent.GetType.IsAssignableTo(typeof(PolicyRuleEventContent)) + )) { + policyList.Policies.Add(stateEvent); + } + } + + // if (policyList.Policies.Count >= 1) + // await _logRoom?.SendMessageEventAsync( + // MessageFormatter.FormatSuccess($"Loaded {policyList.Policies.Count} policies for {MessageFormatter.HtmlFormatMention(room.RoomId)}!"))!; + + return policyList; + } + + + public async Task ReloadActivePolicyListById(string roomId) { + if (!ActivePolicyLists.Any(x => x.Room.RoomId == roomId)) return; + await LoadPolicyListAsync(hs.GetRoom(roomId), ActivePolicyLists.Single(x => x.Room.RoomId == roomId)); + ActivePolicies = await GetActivePolicies(); + } + + public async Task> GetActivePolicies() { + var sw = Stopwatch.StartNew(); + List activePolicies = new(); + + foreach (var activePolicyList in ActivePolicyLists) { + foreach (var policyEntry in activePolicyList.Policies) { + // TODO: implement rule translation + BasePolicy policy = policyEntry.TypedContent is BasePolicy ? policyEntry.TypedContent as BasePolicy : policyEntry.RawContent.Deserialize(); + if (policy.Entity is null) continue; + policy.PolicyList = activePolicyList; + policy.OriginalEvent = policyEntry; + activePolicies.Add(policy); + } + } + + Console.WriteLine($"Translated policy list data in {sw.Elapsed}"); + ActivePoliciesByType = activePolicies.GroupBy(x => x.GetType().Name).ToDictionary(x => x.Key, x => x.ToList()); + await _logRoom.SendMessageEventAsync(MessageFormatter.FormatSuccess($"Translated policy list data in {sw.GetElapsedAndRestart()}")); + // await _logRoom.SendMessageEventAsync(MessageFormatter.FormatSuccess($"Built policy type map in {sw.GetElapsedAndRestart()}")); + + var summary = SummariseStateTypeCounts(activePolicies.Select(x => x.OriginalEvent).ToList()); + await _logRoom?.SendMessageEventAsync(new RoomMessageEventContent() { + Body = summary.Raw, + FormattedBody = summary.Html, + Format = "org.matrix.custom.html" + })!; + + return activePolicies; + } + + public async Task> GetMatchingPolicies(StateEventResponse @event) { + List matchingPolicies = new(); + if (@event.Sender == @hs.UserId) return matchingPolicies; //ignore self at all costs + + if (ActivePoliciesByType.TryGetValue(nameof(ServerPolicyRuleEventContent), out var serverPolicies)) { + var userServer = @event.Sender.Split(':', 2)[1]; + matchingPolicies.AddRange(serverPolicies.Where(x => x.Entity == userServer)); + } + + if (ActivePoliciesByType.TryGetValue(nameof(UserPolicyRuleEventContent), out var userPolicies)) { + matchingPolicies.AddRange(userPolicies.Where(x => x.Entity == @event.Sender)); + } + + if (@event.TypedContent is RoomMessageEventContent msgContent) { + matchingPolicies.AddRange(await CheckMessageContent(@event)); + if (msgContent.MessageType == "m.text" || msgContent.MessageType == "m.notice") ; //TODO: implement word etc. filters + if (msgContent.MessageType == "m.image" || msgContent.MessageType == "m.file" || msgContent.MessageType == "m.audio" || msgContent.MessageType == "m.video") + matchingPolicies.AddRange(await CheckMedia(@event)); + } + + return matchingPolicies; + } + +#region Policy matching + + private async Task> CheckMessageContent(StateEventResponse @event) { + var matchedRules = new List(); + var msgContent = @event.TypedContent as RoomMessageEventContent; + + if (ActivePoliciesByType.TryGetValue(nameof(MessagePolicyContainsText), out var messageContainsPolicies)) + foreach (var policy in messageContainsPolicies) { + if((@msgContent?.Body?.ToLowerInvariant().Contains(policy.Entity.ToLowerInvariant()) ?? false) || (@msgContent?.FormattedBody?.ToLowerInvariant().Contains(policy.Entity.ToLowerInvariant()) ?? false)) + matchedRules.Add(policy); + } + + + return matchedRules; + } + + private async Task> CheckMedia(StateEventResponse @event) { + var matchedRules = new List(); + var hashAlgo = SHA3_256.Create(); + + var mxcUri = @event.RawContent["url"].GetValue(); + + //check server policies before bothering with hashes + if (ActivePoliciesByType.TryGetValue(nameof(MediaPolicyHomeserver), out var mediaHomeserverPolicies)) + foreach (var policy in mediaHomeserverPolicies) { + logger.LogInformation("Checking rule {rule}: {data}", policy.OriginalEvent.StateKey, policy.OriginalEvent.TypedContent.ToJson(ignoreNull: true, indent: false)); + policy.Entity = policy.Entity.Replace("\\*", ".*").Replace("\\?", "."); + var regex = new Regex($"mxc://({policy.Entity})/.*", RegexOptions.Compiled | RegexOptions.IgnoreCase); + if (regex.IsMatch(@event.RawContent["url"]!.GetValue())) { + logger.LogInformation("{url} matched rule {rule}", @event.RawContent["url"], policy.ToJson(ignoreNull: true)); + matchedRules.Add(policy); + // continue; + } + } + + var resolvedUri = await hsResolver.ResolveMediaUri(mxcUri.Split('/')[2], mxcUri); + var uriHash = hashAlgo.ComputeHash(mxcUri.AsBytes().ToArray()); + byte[]? fileHash = null; + + try { + fileHash = await hashAlgo.ComputeHashAsync(await hs.ClientHttpClient.GetStreamAsync(resolvedUri)); + } + catch (Exception ex) { + await _logRoom.SendMessageEventAsync( + MessageFormatter.FormatException($"Error calculating file hash for {mxcUri} via {mxcUri.Split('/')[2]} ({resolvedUri}), retrying via {hs.BaseUrl}...", + ex)); + try { + resolvedUri = await hsResolver.ResolveMediaUri(hs.BaseUrl, mxcUri); + fileHash = await hashAlgo.ComputeHashAsync(await hs.ClientHttpClient.GetStreamAsync(resolvedUri)); + } + catch (Exception ex2) { + await _logRoom.SendMessageEventAsync( + MessageFormatter.FormatException($"Error calculating file hash via {hs.BaseUrl} ({resolvedUri})!", ex2)); + } + } + + logger.LogInformation("Checking media {url} with hash {hash}", resolvedUri, fileHash); + + if (ActivePoliciesByType.ContainsKey(nameof(MediaPolicyFile))) + foreach (MediaPolicyFile policy in ActivePoliciesByType[nameof(MediaPolicyFile)]) { + logger.LogInformation("Checking rule {rule}: {data}", policy.OriginalEvent.StateKey, policy.OriginalEvent.TypedContent.ToJson(ignoreNull: true, indent: false)); + if (policy.Entity is not null && Convert.ToBase64String(uriHash).SequenceEqual(policy.Entity)) { + logger.LogInformation("{url} matched rule {rule} by uri hash", @event.RawContent["url"], policy.ToJson(ignoreNull: true)); + matchedRules.Add(policy); + // continue; + } + else logger.LogInformation("uri hash {uriHash} did not match rule's {ruleUriHash}", Convert.ToHexString(uriHash), policy.Entity); + + if (policy.FileHash is not null && fileHash is not null && policy.FileHash == Convert.ToBase64String(fileHash)) { + logger.LogInformation("{url} matched rule {rule} by file hash", @event.RawContent["url"], policy.ToJson(ignoreNull: true)); + matchedRules.Add(policy); + // continue; + } + else logger.LogInformation("file hash {fileHash} did not match rule's {ruleFileHash}", Convert.ToBase64String(fileHash), policy.FileHash); + + //check pixels every 10% of the way through the image using ImageSharp + // var image = Image.Load(await _hs._httpClient.GetStreamAsync(resolvedUri)); + } + else logger.LogInformation("No active media file policies"); + // logger.LogInformation("{url} did not match any rules", @event.RawContent["url"]); + + return matchedRules; + } + +#endregion + +#region Internal code + +#region Summarisation + + private static (string Raw, string Html) SummariseStateTypeCounts(IList states) { + string raw = "Count | State type | Mapped type", html = ""; + var groupedStates = states.GroupBy(x => x.Type).ToDictionary(x => x.Key, x => x.ToList()).OrderByDescending(x => x.Value.Count); + foreach (var (type, stateGroup) in groupedStates) { + raw += $"{stateGroup.Count} | {type} | {stateGroup[0].GetType.Name}"; + html += $""; + } + + html += "
CountState typeMapped type
{stateGroup.Count}{type}{stateGroup[0].GetType.Name}
"; + return (raw, html); + } + +#endregion + +#endregion + +} \ No newline at end of file diff --git a/PolicyList.cs b/PolicyList.cs new file mode 100644 index 0000000..a3052bd --- /dev/null +++ b/PolicyList.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; +using LibMatrix; +using LibMatrix.RoomTypes; +using ModerationBot.StateEventTypes; + +namespace ModerationBot; + +public class PolicyList { + [JsonIgnore] + public GenericRoom Room { get; set; } + + [JsonPropertyName("trusted")] + public bool Trusted { get; set; } = false; + + [JsonIgnore] + public List Policies { get; set; } = new(); +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..b41b0be --- /dev/null +++ b/Program.cs @@ -0,0 +1,28 @@ +// See https://aka.ms/new-console-template for more information + +using LibMatrix.Services; +using LibMatrix.Utilities.Bot; +using ModerationBot; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +Console.WriteLine("Hello, World!"); + +var host = Host.CreateDefaultBuilder(args).ConfigureServices((_, services) => { + services.AddScoped(x => + new TieredStorageService( + cacheStorageProvider: new FileStorageProvider("bot_data/cache/"), + dataStorageProvider: new FileStorageProvider("bot_data/data/") + ) + ); + services.AddSingleton(); + + services.AddRoryLibMatrixServices(); + services.AddBot(withCommands: true); + + services.AddSingleton(); + + services.AddHostedService(); +}).UseConsoleLifetime().Build(); + +await host.RunAsync(); diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..997e294 --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,26 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "Default": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + + } + }, + "Development": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + }, + "Local config": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Local" + } + } + } +} diff --git a/StateEventTypes/Policies/BasePolicy.cs b/StateEventTypes/Policies/BasePolicy.cs new file mode 100644 index 0000000..94b2f63 --- /dev/null +++ b/StateEventTypes/Policies/BasePolicy.cs @@ -0,0 +1,52 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using LibMatrix; +using LibMatrix.Interfaces; + +namespace ModerationBot.StateEventTypes.Policies; + +public abstract class BasePolicy : EventContent { + /// + /// Entity this policy applies to, null if event was redacted + /// + [JsonPropertyName("entity")] + public string? Entity { get; set; } + + /// + /// Reason this policy exists + /// + [JsonPropertyName("reason")] + public string? Reason { get; set; } + + /// + /// Suggested action to take, one of `ban`, `kick`, `mute`, `redact`, `spoiler`, `warn` or `warn_admins` + /// + [JsonPropertyName("recommendation")] + [AllowedValues("ban", "kick", "mute", "redact", "spoiler", "warn", "warn_admins")] + public string Recommendation { get; set; } = "warn"; + + /// + /// Expiry time in milliseconds since the unix epoch, or null if the ban has no expiry. + /// + [JsonPropertyName("support.feline.policy.expiry.rev.2")] //stable prefix: expiry, msc pending + public long? Expiry { get; set; } + + //utils + /// + /// Readable expiry time, provided for easy interaction + /// + [JsonPropertyName("gay.rory.matrix_room_utils.readable_expiry_time_utc")] + public DateTime? ExpiryDateTime { + get => Expiry == null ? null : DateTimeOffset.FromUnixTimeMilliseconds(Expiry.Value).DateTime; + set => Expiry = value is null ? null : ((DateTimeOffset)value).ToUnixTimeMilliseconds(); + } + +#region Internal metadata + + [JsonIgnore] + public PolicyList PolicyList { get; set; } + + public StateEventResponse OriginalEvent { get; set; } + +#endregion +} diff --git a/StateEventTypes/Policies/Implementations/MediaPolicyFile.cs b/StateEventTypes/Policies/Implementations/MediaPolicyFile.cs new file mode 100644 index 0000000..c5b6ef2 --- /dev/null +++ b/StateEventTypes/Policies/Implementations/MediaPolicyFile.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; +using LibMatrix.EventTypes; + +namespace ModerationBot.StateEventTypes.Policies.Implementations; + +/// +/// File policy event, entity is the MXC URI of the file, hashed with SHA3-256. +/// +[MatrixEvent(EventName = "gay.rory.moderation.rule.media")] +public class MediaPolicyFile : BasePolicy { + /// + /// Hash of the file + /// + [JsonPropertyName("file_hash")] + public string? FileHash { get; set; } +} diff --git a/StateEventTypes/Policies/Implementations/MediaPolicyHomeserver.cs b/StateEventTypes/Policies/Implementations/MediaPolicyHomeserver.cs new file mode 100644 index 0000000..3dfd937 --- /dev/null +++ b/StateEventTypes/Policies/Implementations/MediaPolicyHomeserver.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; +using LibMatrix.EventTypes; + +namespace ModerationBot.StateEventTypes.Policies.Implementations; + +/// +/// Homeserver media policy event, entity is the MXC URI of the file, hashed with SHA3-256. +/// +[MatrixEvent(EventName = "gay.rory.moderation.rule.media.homeserver")] +public class MediaPolicyHomeserver : BasePolicy { +} diff --git a/StateEventTypes/Policies/Implementations/MessagePolicyContainsText.cs b/StateEventTypes/Policies/Implementations/MessagePolicyContainsText.cs new file mode 100644 index 0000000..daac162 --- /dev/null +++ b/StateEventTypes/Policies/Implementations/MessagePolicyContainsText.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; +using LibMatrix.EventTypes; + +namespace ModerationBot.StateEventTypes.Policies.Implementations; + +/// +/// Text contains policy event, entity is the text to contain. +/// +[MatrixEvent(EventName = "gay.rory.moderation.rule.text.contains")] +public class MessagePolicyContainsText : BasePolicy { +} diff --git a/StateEventTypes/Policies/Implementations/UnknownPolicy.cs b/StateEventTypes/Policies/Implementations/UnknownPolicy.cs new file mode 100644 index 0000000..8dc8258 --- /dev/null +++ b/StateEventTypes/Policies/Implementations/UnknownPolicy.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; +using LibMatrix.EventTypes; + +namespace ModerationBot.StateEventTypes.Policies.Implementations; + +/// +/// Unknown policy event, usually used for handling unknown cases +/// +public class UnknownPolicy : BasePolicy { +} diff --git a/appsettings.Development.json b/appsettings.Development.json new file mode 100644 index 0000000..224d0da --- /dev/null +++ b/appsettings.Development.json @@ -0,0 +1,24 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + }, + "LibMatrixBot": { + // The homeserver to connect to + "Homeserver": "rory.gay", + // The access token to use + "AccessToken": "syt_xxxxxxxxxxxxxxxxx", + // The command prefix + "Prefix": "?" + }, + "ModerationBot": { + // List of people who should be invited to the control room + "Admins": [ + "@emma:conduit.rory.gay", + "@emma:rory.gay" + ] + } +} diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..6ba02f3 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} -- cgit 1.5.1