From e5591eef3850a9796cc87386128651a828b70697 Mon Sep 17 00:00:00 2001 From: TheArcaneBrony Date: Fri, 6 Oct 2023 18:29:15 +0200 Subject: Small refactors --- ExampleBots/LibMatrix.ExampleBot/Bot/MRUBot.cs | 9 +- .../MediaModeratorPoC/AccountData/BotData.cs | 11 + .../MediaModeratorPoC/Bot/AccountData/BotData.cs | 14 - .../Bot/Commands/BanMediaCommand.cs | 108 ------- ExampleBots/MediaModeratorPoC/Bot/MediaModBot.cs | 339 --------------------- .../Bot/MediaModBotConfiguration.cs | 10 - .../StateEventTypes/MediaPolicyStateEventData.cs | 53 ---- .../MediaModeratorPoC/Commands/BanMediaCommand.cs | 110 +++++++ ExampleBots/MediaModeratorPoC/MediaModBot.cs | 339 +++++++++++++++++++++ .../MediaModeratorPoC/MediaModBotConfiguration.cs | 10 + ExampleBots/MediaModeratorPoC/PolicyEngine.cs | 86 ++++++ ExampleBots/MediaModeratorPoC/PolicyList.cs | 17 ++ ExampleBots/MediaModeratorPoC/Program.cs | 2 +- .../StateEventTypes/BasePolicy.cs | 42 +++ .../StateEventTypes/MediaPolicyStateEventData.cs | 17 ++ .../PluralContactBotPoC/Bot/AccountData/BotData.cs | 1 + .../Bot/AccountData/SystemData.cs | 1 + .../Bot/Commands/CreateSystemCommand.cs | 2 +- .../PluralContactBotPoC/Bot/PluralContactBot.cs | 10 +- 19 files changed, 648 insertions(+), 533 deletions(-) create mode 100644 ExampleBots/MediaModeratorPoC/AccountData/BotData.cs delete mode 100644 ExampleBots/MediaModeratorPoC/Bot/AccountData/BotData.cs delete mode 100644 ExampleBots/MediaModeratorPoC/Bot/Commands/BanMediaCommand.cs delete mode 100644 ExampleBots/MediaModeratorPoC/Bot/MediaModBot.cs delete mode 100644 ExampleBots/MediaModeratorPoC/Bot/MediaModBotConfiguration.cs delete mode 100644 ExampleBots/MediaModeratorPoC/Bot/StateEventTypes/MediaPolicyStateEventData.cs create mode 100644 ExampleBots/MediaModeratorPoC/Commands/BanMediaCommand.cs create mode 100644 ExampleBots/MediaModeratorPoC/MediaModBot.cs create mode 100644 ExampleBots/MediaModeratorPoC/MediaModBotConfiguration.cs create mode 100644 ExampleBots/MediaModeratorPoC/PolicyEngine.cs create mode 100644 ExampleBots/MediaModeratorPoC/PolicyList.cs create mode 100644 ExampleBots/MediaModeratorPoC/StateEventTypes/BasePolicy.cs create mode 100644 ExampleBots/MediaModeratorPoC/StateEventTypes/MediaPolicyStateEventData.cs (limited to 'ExampleBots') diff --git a/ExampleBots/LibMatrix.ExampleBot/Bot/MRUBot.cs b/ExampleBots/LibMatrix.ExampleBot/Bot/MRUBot.cs index 0211f74..8cf4f1f 100644 --- a/ExampleBots/LibMatrix.ExampleBot/Bot/MRUBot.cs +++ b/ExampleBots/LibMatrix.ExampleBot/Bot/MRUBot.cs @@ -4,6 +4,7 @@ using LibMatrix.EventTypes.Spec; using LibMatrix.EventTypes.Spec.State; using LibMatrix.ExampleBot.Bot.Interfaces; using LibMatrix.Extensions; +using LibMatrix.Helpers; using LibMatrix.Homeservers; using LibMatrix.Services; using Microsoft.Extensions.DependencyInjection; @@ -44,6 +45,8 @@ public class MRUBot : IHostedService { throw; } + var syncHelper = new SyncHelper(hs); + await (hs.GetRoom("!DoHEdFablOLjddKWIp:rory.gay")).JoinAsync(); // foreach (var room in await hs.GetJoinedRooms()) { @@ -54,7 +57,7 @@ public class MRUBot : IHostedService { // _logger.LogInformation($"Got room state for {room.RoomId}!"); // } - hs.SyncHelper.InviteReceivedHandlers.Add(async Task (args) => { + syncHelper.InviteReceivedHandlers.Add(async Task (args) => { var inviteEvent = args.Value.InviteState.Events.FirstOrDefault(x => x.Type == "m.room.member" && x.StateKey == hs.UserId); @@ -71,7 +74,7 @@ public class MRUBot : IHostedService { } } }); - hs.SyncHelper.TimelineEventHandlers.Add(async @event => { + syncHelper.TimelineEventHandlers.Add(async @event => { _logger.LogInformation( "Got timeline event in {}: {}", @event.RoomId, @event.ToJson(indent: false, ignoreNull: true)); @@ -100,7 +103,7 @@ public class MRUBot : IHostedService { } } }); - await hs.SyncHelper.RunSyncLoop(cancellationToken: cancellationToken); + await syncHelper.RunSyncLoopAsync(cancellationToken: cancellationToken); } /// Triggered when the application host is performing a graceful shutdown. diff --git a/ExampleBots/MediaModeratorPoC/AccountData/BotData.cs b/ExampleBots/MediaModeratorPoC/AccountData/BotData.cs new file mode 100644 index 0000000..0fee4eb --- /dev/null +++ b/ExampleBots/MediaModeratorPoC/AccountData/BotData.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace MediaModeratorPoC.AccountData; + +public class BotData { + [JsonPropertyName("control_room")] + public string ControlRoom { get; set; } = ""; + + [JsonPropertyName("log_room")] + public string? LogRoom { get; set; } = ""; +} diff --git a/ExampleBots/MediaModeratorPoC/Bot/AccountData/BotData.cs b/ExampleBots/MediaModeratorPoC/Bot/AccountData/BotData.cs deleted file mode 100644 index b4e1167..0000000 --- a/ExampleBots/MediaModeratorPoC/Bot/AccountData/BotData.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Text.Json.Serialization; - -namespace MediaModeratorPoC.Bot.AccountData; - -public class BotData { - [JsonPropertyName("control_room")] - public string ControlRoom { get; set; } = ""; - - [JsonPropertyName("log_room")] - public string? LogRoom { get; set; } = ""; - - [JsonPropertyName("policy_room")] - public string? PolicyRoom { get; set; } = ""; -} diff --git a/ExampleBots/MediaModeratorPoC/Bot/Commands/BanMediaCommand.cs b/ExampleBots/MediaModeratorPoC/Bot/Commands/BanMediaCommand.cs deleted file mode 100644 index fd6866c..0000000 --- a/ExampleBots/MediaModeratorPoC/Bot/Commands/BanMediaCommand.cs +++ /dev/null @@ -1,108 +0,0 @@ -using System.Security.Cryptography; -using ArcaneLibs.Extensions; -using LibMatrix.EventTypes.Spec; -using LibMatrix.Helpers; -using LibMatrix.Responses; -using LibMatrix.Services; -using LibMatrix.Utilities.Bot.Interfaces; -using MediaModeratorPoC.Bot.AccountData; -using MediaModeratorPoC.Bot.StateEventTypes; - -namespace MediaModeratorPoC.Bot.Commands; - -public class BanMediaCommand(IServiceProvider services, HomeserverProviderService hsProvider, HomeserverResolverService hsResolver) : 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.GetAccountData("gay.rory.media_moderator_poc_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.GetAccountData("gay.rory.media_moderator_poc_data"); - var policyRoom = ctx.Homeserver.GetRoom(botData.PolicyRoom ?? 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._httpClient.GetStreamAsync(resolvedUri)); - } - catch (Exception ex) { - await logRoom.SendMessageEventAsync( - MessageFormatter.FormatException($"Error calculating file hash for {mxcUri} via {mxcUri.Split('/')[2]}, retrying via {ctx.Homeserver.HomeServerDomain}...", - ex)); - try { - resolvedUri = await hsResolver.ResolveMediaUri(ctx.Homeserver.HomeServerDomain, mxcUri); - fileHash = await hashAlgo.ComputeHashAsync(await ctx.Homeserver._httpClient.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.HomeServerDomain}!", ex2)); - } - } - - MediaPolicyEventContent policy; - await policyRoom.SendStateEventAsync("gay.rory.media_moderator_poc.rule.media", Guid.NewGuid().ToString(), policy = new MediaPolicyEventContent { - Entity = uriHash, - FileHash = 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("m.file", "error.log.cs", stream); - } - } - else { - await ctx.Room.SendMessageEventAsync(MessageFormatter.FormatError("This command must be used in reply to a message!")); - } - } -} diff --git a/ExampleBots/MediaModeratorPoC/Bot/MediaModBot.cs b/ExampleBots/MediaModeratorPoC/Bot/MediaModBot.cs deleted file mode 100644 index f9bbcf3..0000000 --- a/ExampleBots/MediaModeratorPoC/Bot/MediaModBot.cs +++ /dev/null @@ -1,339 +0,0 @@ -using System.Buffers.Text; -using System.Data; -using System.Security.Cryptography; -using System.Text; -using System.Text.Encodings.Web; -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 MediaModeratorPoC.Bot.AccountData; -using MediaModeratorPoC.Bot.StateEventTypes; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace MediaModeratorPoC.Bot; - -public class MediaModBot(AuthenticatedHomeserverGeneric hs, ILogger logger, MediaModBotConfiguration configuration, - HomeserverResolverService hsResolver) : 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) { - Directory.GetFiles("bot_data/cache").ToList().ForEach(File.Delete); - - BotData botData; - - try { - botData = await hs.GetAccountData("gay.rory.media_moderator_poc_data"); - } - catch (Exception e) { - if (e is not MatrixException { ErrorCode: "M_NOT_FOUND" }) { - logger.LogError("{}", e.ToString()); - throw; - } - - botData = new BotData(); - var creationContent = CreateRoomRequest.CreatePrivate(hs, name: "Media Moderator PoC - Control room", roomAliasName: "media-moderator-poc-control-room"); - creationContent.Invite = configuration.Admins; - creationContent.CreationContent["type"] = "gay.rory.media_moderator_poc.control_room"; - - 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 JoinRulesEventContent { - JoinRule = "knock_restricted", - Allow = new() { - new JoinRulesEventContent.AllowEntry { - Type = "m.room_membership", - RoomId = botData.ControlRoom - } - } - } - }); - - creationContent.Name = "Media Moderator PoC - Log room"; - creationContent.RoomAliasName = "media-moderator-poc-log-room"; - creationContent.CreationContent["type"] = "gay.rory.media_moderator_poc.log_room"; - botData.LogRoom = (await hs.CreateRoom(creationContent)).RoomId; - - creationContent.Name = "Media Moderator PoC - Policy room"; - creationContent.RoomAliasName = "media-moderator-poc-policy-room"; - creationContent.CreationContent["type"] = "gay.rory.media_moderator_poc.policy_room"; - botData.PolicyRoom = (await hs.CreateRoom(creationContent)).RoomId; - - await hs.SetAccountData("gay.rory.media_moderator_poc_data", botData); - } - - _policyRoom = hs.GetRoom(botData.PolicyRoom ?? botData.ControlRoom); - _logRoom = hs.GetRoom(botData.LogRoom ?? botData.ControlRoom); - _controlRoom = hs.GetRoom(botData.ControlRoom); - - 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.UserId); - } - - await Task.Delay(TimeSpan.FromSeconds(30), cancellationToken); - } - }, cancellationToken); -#pragma warning restore CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed - - hs.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 {args.Key} by {inviteEvent.Sender} with reason: {(inviteEvent.TypedContent as RoomMemberEventContent).Reason}"); - if (inviteEvent.Sender.EndsWith(":rory.gay") || inviteEvent.Sender.EndsWith(":conduit.rory.gay")) { - try { - 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 hs.GetRoom(args.Key).LeaveAsync(reason: "I was unable to join the room: " + e); - } - } - }); - - hs.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 is { Type: "m.room.message", TypedContent: RoomMessageEventContent message }) { - if (message is { MessageType: "m.image" }) { - //check media - var matchedPolicy = await CheckMedia(@event); - if (matchedPolicy is null) return; - var matchedpolicyData = matchedPolicy.TypedContent as MediaPolicyEventContent; - var recommendation = matchedpolicyData.Recommendation; - 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 (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 ban user in {MessageFormatter.HtmlFormatMention(room.RoomId)}", e)); - await _logRoom.SendMessageEventAsync( - MessageFormatter.FormatException($"Unable to ban user in {MessageFormatter.HtmlFormatMention(room.RoomId)}", e)); - await using var stream = new MemoryStream(e.ToString().AsBytes().ToArray()); - await _controlRoom.SendFileAsync("m.file", "error.log.cs", stream); - await _logRoom.SendFileAsync("m.file", "error.log.cs", stream); - } - }); - } - - /// 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!"); - } - - private async Task CheckMedia(StateEventResponse @event) { - var stateList = _policyRoom.GetFullStateAsync(); - var hashAlgo = SHA3_256.Create(); - - var mxcUri = @event.RawContent["url"].GetValue(); - 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._httpClient.GetStreamAsync(resolvedUri)); - } - catch (Exception ex) { - await _logRoom.SendMessageEventAsync( - MessageFormatter.FormatException($"Error calculating file hash for {mxcUri} via {mxcUri.Split('/')[2]} ({resolvedUri}), retrying via {hs.HomeServerDomain}...", - ex)); - try { - resolvedUri = await hsResolver.ResolveMediaUri(hs.HomeServerDomain, mxcUri); - fileHash = await hashAlgo.ComputeHashAsync(await hs._httpClient.GetStreamAsync(resolvedUri)); - } - catch (Exception ex2) { - await _logRoom.SendMessageEventAsync( - MessageFormatter.FormatException($"Error calculating file hash via {hs.HomeServerDomain} ({resolvedUri})!", ex2)); - } - } - - logger.LogInformation("Checking media {url} with hash {hash}", resolvedUri, fileHash); - - await foreach (var state in stateList) { - if (state.Type != "gay.rory.media_moderator_poc.rule.media" && state.Type != "gay.rory.media_moderator_poc.rule.server") continue; - if (!state.RawContent.ContainsKey("entity")) { - logger.LogWarning("Rule {rule} has no entity, this event was probably redacted!", state.StateKey); - continue; - } - - logger.LogInformation("Checking rule {rule}: {data}", state.StateKey, state.TypedContent.ToJson(ignoreNull: true, indent: false)); - var rule = state.TypedContent as MediaPolicyEventContent; - if (state.Type == "gay.rory.media_moderator_poc.rule.server" && rule.ServerEntity is not null) { - rule.ServerEntity = rule.ServerEntity.Replace("\\*", ".*").Replace("\\?", "."); - var regex = new Regex($"mxc://({rule.ServerEntity})/.*", RegexOptions.Compiled | RegexOptions.IgnoreCase); - if (regex.IsMatch(@event.RawContent["url"]!.GetValue())) { - logger.LogInformation("{url} matched rule {rule}", @event.RawContent["url"], rule.ToJson(ignoreNull: true)); - return state; - } - } - - if (rule.Entity is not null && uriHash.SequenceEqual(rule.Entity)) { - logger.LogInformation("{url} matched rule {rule} by uri hash", @event.RawContent["url"], rule.ToJson(ignoreNull: true)); - return state; - } - - logger.LogInformation("uri hash {uriHash} did not match rule's {ruleUriHash}", Convert.ToBase64String(uriHash), Convert.ToBase64String(rule.Entity)); - - if (rule.FileHash is not null && fileHash is not null && rule.FileHash.SequenceEqual(fileHash)) { - logger.LogInformation("{url} matched rule {rule} by file hash", @event.RawContent["url"], rule.ToJson(ignoreNull: true)); - return state; - } - - logger.LogInformation("file hash {fileHash} did not match rule's {ruleFileHash}", Convert.ToBase64String(fileHash), Convert.ToBase64String(rule.FileHash)); - - //check pixels every 10% of the way through the image using ImageSharp - // var image = Image.Load(await _hs._httpClient.GetStreamAsync(resolvedUri)); - } - - logger.LogInformation("{url} did not match any rules", @event.RawContent["url"]); - - return null; - } -} diff --git a/ExampleBots/MediaModeratorPoC/Bot/MediaModBotConfiguration.cs b/ExampleBots/MediaModeratorPoC/Bot/MediaModBotConfiguration.cs deleted file mode 100644 index d848abe..0000000 --- a/ExampleBots/MediaModeratorPoC/Bot/MediaModBotConfiguration.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Microsoft.Extensions.Configuration; - -namespace MediaModeratorPoC.Bot; - -public class MediaModBotConfiguration { - public MediaModBotConfiguration(IConfiguration config) => config.GetRequiredSection("MediaMod").Bind(this); - - public List Admins { get; set; } = new(); - public bool DemoMode { get; set; } = false; -} diff --git a/ExampleBots/MediaModeratorPoC/Bot/StateEventTypes/MediaPolicyStateEventData.cs b/ExampleBots/MediaModeratorPoC/Bot/StateEventTypes/MediaPolicyStateEventData.cs deleted file mode 100644 index 0096c78..0000000 --- a/ExampleBots/MediaModeratorPoC/Bot/StateEventTypes/MediaPolicyStateEventData.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System.Text.Json.Serialization; -using LibMatrix.Helpers; -using LibMatrix.Interfaces; - -namespace MediaModeratorPoC.Bot.StateEventTypes; - -[ - MatrixEvent(EventName = "gay.rory.media_moderator_poc.rule.homeserver")] -[MatrixEvent(EventName = "gay.rory.media_moderator_poc.rule.media")] -public class MediaPolicyEventContent : EventContent { - /// - /// This is an MXC URI, hashed with SHA3-256. - /// - [JsonPropertyName("entity")] - public byte[] Entity { get; set; } - - /// - /// Server this ban applies to, can use * and ? as globs. - /// - [JsonPropertyName("server_entity")] - public string? ServerEntity { get; set; } - - /// - /// Reason this user is banned - /// - [JsonPropertyName("reason")] - public string? Reason { get; set; } - - /// - /// Suggested action to take, one of `ban`, `kick`, `mute`, `redact`, `spoiler`, `warn` or `warn_admins` - /// - [JsonPropertyName("recommendation")] - 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(); - } - - [JsonPropertyName("file_hash")] - public byte[]? FileHash { get; set; } -} diff --git a/ExampleBots/MediaModeratorPoC/Commands/BanMediaCommand.cs b/ExampleBots/MediaModeratorPoC/Commands/BanMediaCommand.cs new file mode 100644 index 0000000..69c0583 --- /dev/null +++ b/ExampleBots/MediaModeratorPoC/Commands/BanMediaCommand.cs @@ -0,0 +1,110 @@ +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 MediaModeratorPoC.AccountData; +using MediaModeratorPoC.StateEventTypes; + +namespace MediaModeratorPoC.Commands; + +public class BanMediaCommand(IServiceProvider services, HomeserverProviderService hsProvider, HomeserverResolverService hsResolver) : 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.modbot_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.modbot_data"); + var policyRoom = ctx.Homeserver.GetRoom(botData.PolicyRoom ?? 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._httpClient.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._httpClient.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)); + } + } + + MediaPolicyEventContent policy; + await policyRoom.SendStateEventAsync("gay.rory.media_moderator_poc.rule.media", Guid.NewGuid().ToString(), policy = new MediaPolicyEventContent { + Entity = uriHash, + FileHash = 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/ExampleBots/MediaModeratorPoC/MediaModBot.cs b/ExampleBots/MediaModeratorPoC/MediaModBot.cs new file mode 100644 index 0000000..0aacf61 --- /dev/null +++ b/ExampleBots/MediaModeratorPoC/MediaModBot.cs @@ -0,0 +1,339 @@ +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 MediaModeratorPoC.AccountData; +using MediaModeratorPoC.StateEventTypes; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace MediaModeratorPoC; + +public class MediaModBot(AuthenticatedHomeserverGeneric hs, ILogger logger, MediaModBotConfiguration configuration, + HomeserverResolverService hsResolver) : 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) { + Directory.GetFiles("bot_data/cache").ToList().ForEach(File.Delete); + + BotData botData; + + try { + botData = await hs.GetAccountDataAsync("gay.rory.modbot_data"); + } + catch (Exception e) { + if (e is not MatrixException { ErrorCode: "M_NOT_FOUND" }) { + logger.LogError("{}", e.ToString()); + throw; + } + + botData = new BotData(); + var creationContent = CreateRoomRequest.CreatePrivate(hs, name: "Media Moderator PoC - Control room", roomAliasName: "media-moderator-poc-control-room"); + creationContent.Invite = configuration.Admins; + creationContent.CreationContent["type"] = "gay.rory.media_moderator_poc.control_room"; + + 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 = "Media Moderator PoC - Log room"; + creationContent.RoomAliasName = "media-moderator-poc-log-room"; + creationContent.CreationContent["type"] = "gay.rory.media_moderator_poc.log_room"; + botData.LogRoom = (await hs.CreateRoom(creationContent)).RoomId; + + creationContent.Name = "Media Moderator PoC - Policy room"; + creationContent.RoomAliasName = "media-moderator-poc-policy-room"; + creationContent.CreationContent["type"] = "gay.rory.media_moderator_poc.policy_room"; + botData.PolicyRoom = (await hs.CreateRoom(creationContent)).RoomId; + + await hs.SetAccountData("gay.rory.modbot_data", botData); + } + + _policyRoom = hs.GetRoom(botData.PolicyRoom ?? botData.ControlRoom); + _logRoom = hs.GetRoom(botData.LogRoom ?? botData.ControlRoom); + _controlRoom = hs.GetRoom(botData.ControlRoom); + 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.UserId); + } + + await Task.Delay(TimeSpan.FromSeconds(30), 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); + if (inviteEvent.Sender.EndsWith(":rory.gay") || inviteEvent!.Sender.EndsWith(":conduit.rory.gay")) { + try { + 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 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 is { Type: "m.room.message", TypedContent: RoomMessageEventContent message }) { + if (message is { MessageType: "m.image" }) { + //check media + var matchedPolicy = await CheckMedia(@event); + if (matchedPolicy is null) return; + var matchedpolicyData = matchedPolicy.TypedContent as MediaPolicyEventContent; + var recommendation = matchedpolicyData.Recommendation; + 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 (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 ban user in {MessageFormatter.HtmlFormatMention(room.RoomId)}", e)); + await _logRoom.SendMessageEventAsync( + MessageFormatter.FormatException($"Unable to ban user 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); + } + }); + } + + /// 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!"); + } + + private async Task CheckMedia(StateEventResponse @event) { + var stateList = _policyRoom.GetFullStateAsync(); + var hashAlgo = SHA3_256.Create(); + + var mxcUri = @event.RawContent["url"].GetValue(); + 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._httpClient.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._httpClient.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); + + await foreach (var state in stateList) { + if (state.Type != "gay.rory.media_moderator_poc.rule.media" && state.Type != "gay.rory.media_moderator_poc.rule.server") continue; + if (!state.RawContent.ContainsKey("entity")) { + logger.LogWarning("Rule {rule} has no entity, this event was probably redacted!", state.StateKey); + continue; + } + + logger.LogInformation("Checking rule {rule}: {data}", state.StateKey, state.TypedContent.ToJson(ignoreNull: true, indent: false)); + var rule = state.TypedContent as MediaPolicyEventContent; + if (state.Type == "gay.rory.media_moderator_poc.rule.server" && rule.ServerEntity is not null) { + rule.ServerEntity = rule.ServerEntity.Replace("\\*", ".*").Replace("\\?", "."); + var regex = new Regex($"mxc://({rule.ServerEntity})/.*", RegexOptions.Compiled | RegexOptions.IgnoreCase); + if (regex.IsMatch(@event.RawContent["url"]!.GetValue())) { + logger.LogInformation("{url} matched rule {rule}", @event.RawContent["url"], rule.ToJson(ignoreNull: true)); + return state; + } + } + + if (rule.Entity is not null && uriHash.SequenceEqual(rule.Entity)) { + logger.LogInformation("{url} matched rule {rule} by uri hash", @event.RawContent["url"], rule.ToJson(ignoreNull: true)); + return state; + } + + logger.LogInformation("uri hash {uriHash} did not match rule's {ruleUriHash}", Convert.ToBase64String(uriHash), Convert.ToBase64String(rule.Entity)); + + if (rule.FileHash is not null && fileHash is not null && rule.FileHash.SequenceEqual(fileHash)) { + logger.LogInformation("{url} matched rule {rule} by file hash", @event.RawContent["url"], rule.ToJson(ignoreNull: true)); + return state; + } + + logger.LogInformation("file hash {fileHash} did not match rule's {ruleFileHash}", Convert.ToBase64String(fileHash), Convert.ToBase64String(rule.FileHash)); + + //check pixels every 10% of the way through the image using ImageSharp + // var image = Image.Load(await _hs._httpClient.GetStreamAsync(resolvedUri)); + } + + logger.LogInformation("{url} did not match any rules", @event.RawContent["url"]); + + return null; + } +} diff --git a/ExampleBots/MediaModeratorPoC/MediaModBotConfiguration.cs b/ExampleBots/MediaModeratorPoC/MediaModBotConfiguration.cs new file mode 100644 index 0000000..cb5b596 --- /dev/null +++ b/ExampleBots/MediaModeratorPoC/MediaModBotConfiguration.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.Configuration; + +namespace MediaModeratorPoC; + +public class MediaModBotConfiguration { + public MediaModBotConfiguration(IConfiguration config) => config.GetRequiredSection("MediaMod").Bind(this); + + public List Admins { get; set; } = new(); + public bool DemoMode { get; set; } = false; +} diff --git a/ExampleBots/MediaModeratorPoC/PolicyEngine.cs b/ExampleBots/MediaModeratorPoC/PolicyEngine.cs new file mode 100644 index 0000000..0a0a565 --- /dev/null +++ b/ExampleBots/MediaModeratorPoC/PolicyEngine.cs @@ -0,0 +1,86 @@ +using LibMatrix.EventTypes.Spec; +using LibMatrix.Helpers; +using LibMatrix.Homeservers; +using LibMatrix.RoomTypes; +using LibMatrix.Services; +using MediaModeratorPoC.AccountData; +using MediaModeratorPoC.StateEventTypes; +using Microsoft.Extensions.Logging; + +namespace MediaModeratorPoC; + +public class PolicyEngine(AuthenticatedHomeserverGeneric hs, ILogger logger, MediaModBotConfiguration configuration, + HomeserverResolverService hsResolver) { + public List ActivePolicyLists { get; set; } = new(); + private GenericRoom? _logRoom; + private GenericRoom? _controlRoom; + + public async Task ReloadActivePolicyLists() { + // first time init + if (_logRoom is null || _controlRoom is null) { + var botData = await hs.GetAccountDataAsync("gay.rory.modbot_data"); + _logRoom ??= hs.GetRoom(botData.LogRoom ?? botData.ControlRoom); + _controlRoom ??= hs.GetRoom(botData.ControlRoom); + } + + await _controlRoom?.SendMessageEventAsync(MessageFormatter.FormatSuccess("Reloading policy lists!"))!; + await _logRoom?.SendMessageEventAsync( + new RoomMessageEventContent( + body: "Reloading policy lists!", + messageType: "m.text"))!; + + await _controlRoom?.SendMessageEventAsync(MessageFormatter.FormatSuccess("0/? policy lists loaded"))!; + + var policyLists = new List(); + var policyListAccountData = await hs.GetAccountDataAsync>("gay.rory.modbot.policy_lists"); + foreach (var (roomId, policyList) in policyListAccountData) { + _logRoom?.SendMessageEventAsync( + new RoomMessageEventContent( + body: $"Loading policy list {MessageFormatter.HtmlFormatMention(roomId)}!", + messageType: "m.text")); + var room = hs.GetRoom(roomId); + + policyList.Room = room; + + var stateEvents = room.GetFullStateAsync(); + await foreach (var stateEvent in stateEvents) { + if (stateEvent != null && stateEvent.GetType.IsAssignableTo(typeof(BasePolicy))) { + policyList.Policies.Add(stateEvent); + } + } + + //html table of policy count by type + var policyCount = policyList.Policies.GroupBy(x => x.Type).ToDictionary(x => x.Key, x => x.Count()); + var policyCountTable = policyCount.Aggregate( + "", + (current, policy) => current + $""); + policyCountTable += "
Policy TypeCount
{policy.Key}{policy.Value}
"; + + var policyCountTablePlainText = policyCount.Aggregate( + "Policy Type | Count\n", + (current, policy) => current + $"{policy.Key,-16} | {policy.Value}\n"); + await _logRoom?.SendMessageEventAsync( + new RoomMessageEventContent() { + MessageType = "org.matrix.custom.html", + Body = $"Policy count for {roomId}:\n{policyCountTablePlainText}", + FormattedBody = $"Policy count for {MessageFormatter.HtmlFormatMention(roomId)}:\n{policyCountTable}", + })!; + + await _logRoom?.SendMessageEventAsync( + new RoomMessageEventContent( + body: $"Loaded {policyList.Policies.Count} policies for {MessageFormatter.HtmlFormatMention(roomId)}!", + messageType: "m.text"))!; + + policyLists.Add(policyList); + + var progressMsgContent = MessageFormatter.FormatSuccess($"{policyLists.Count}/{policyListAccountData.Count} policy lists loaded"); + //edit old message + progressMsgContent.RelatesTo = new() { + + }; + _controlRoom?.SendMessageEventAsync(progressMsgContent); + } + + ActivePolicyLists = policyLists; + } +} diff --git a/ExampleBots/MediaModeratorPoC/PolicyList.cs b/ExampleBots/MediaModeratorPoC/PolicyList.cs new file mode 100644 index 0000000..0f49c97 --- /dev/null +++ b/ExampleBots/MediaModeratorPoC/PolicyList.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; +using LibMatrix; +using LibMatrix.RoomTypes; +using MediaModeratorPoC.StateEventTypes; + +namespace MediaModeratorPoC; + +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/ExampleBots/MediaModeratorPoC/Program.cs b/ExampleBots/MediaModeratorPoC/Program.cs index 413d91d..5b8e734 100644 --- a/ExampleBots/MediaModeratorPoC/Program.cs +++ b/ExampleBots/MediaModeratorPoC/Program.cs @@ -2,7 +2,7 @@ using LibMatrix.Services; using LibMatrix.Utilities.Bot; -using MediaModeratorPoC.Bot; +using MediaModeratorPoC; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; diff --git a/ExampleBots/MediaModeratorPoC/StateEventTypes/BasePolicy.cs b/ExampleBots/MediaModeratorPoC/StateEventTypes/BasePolicy.cs new file mode 100644 index 0000000..048c1d0 --- /dev/null +++ b/ExampleBots/MediaModeratorPoC/StateEventTypes/BasePolicy.cs @@ -0,0 +1,42 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using LibMatrix; + +namespace MediaModeratorPoC.StateEventTypes; + +public abstract class BasePolicy : StateEvent { + /// + /// Entity this policy applies to + /// + [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(); + } +} diff --git a/ExampleBots/MediaModeratorPoC/StateEventTypes/MediaPolicyStateEventData.cs b/ExampleBots/MediaModeratorPoC/StateEventTypes/MediaPolicyStateEventData.cs new file mode 100644 index 0000000..603a858 --- /dev/null +++ b/ExampleBots/MediaModeratorPoC/StateEventTypes/MediaPolicyStateEventData.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; +using LibMatrix.EventTypes; + +namespace MediaModeratorPoC.StateEventTypes; + +/// +/// File policy event, entity is the MXC URI of the file, hashed with SHA3-256. +/// +[MatrixEvent(EventName = "gay.rory.media_moderator_poc.rule.homeserver")] +[MatrixEvent(EventName = "gay.rory.media_moderator_poc.rule.media")] +public class MediaPolicyEventContent : BasePolicy { + /// + /// Hash of the file + /// + [JsonPropertyName("file_hash")] + public byte[]? FileHash { get; set; } +} diff --git a/ExampleBots/PluralContactBotPoC/Bot/AccountData/BotData.cs b/ExampleBots/PluralContactBotPoC/Bot/AccountData/BotData.cs index 9477488..5d11432 100644 --- a/ExampleBots/PluralContactBotPoC/Bot/AccountData/BotData.cs +++ b/ExampleBots/PluralContactBotPoC/Bot/AccountData/BotData.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using LibMatrix.EventTypes; using LibMatrix.Helpers; using LibMatrix.Interfaces; diff --git a/ExampleBots/PluralContactBotPoC/Bot/AccountData/SystemData.cs b/ExampleBots/PluralContactBotPoC/Bot/AccountData/SystemData.cs index 5edfc0e..42edd23 100644 --- a/ExampleBots/PluralContactBotPoC/Bot/AccountData/SystemData.cs +++ b/ExampleBots/PluralContactBotPoC/Bot/AccountData/SystemData.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using LibMatrix.EventTypes; using LibMatrix.Helpers; using LibMatrix.Interfaces; diff --git a/ExampleBots/PluralContactBotPoC/Bot/Commands/CreateSystemCommand.cs b/ExampleBots/PluralContactBotPoC/Bot/Commands/CreateSystemCommand.cs index 55624a8..4a7d646 100644 --- a/ExampleBots/PluralContactBotPoC/Bot/Commands/CreateSystemCommand.cs +++ b/ExampleBots/PluralContactBotPoC/Bot/Commands/CreateSystemCommand.cs @@ -25,7 +25,7 @@ public class CreateSystemCommand(IServiceProvider services, HomeserverProviderSe var sysName = ctx.Args[0]; try { try { - await ctx.Homeserver.GetAccountData("gay.rory.plural_contact_bot.system_data"); + await ctx.Homeserver.GetAccountDataAsync("gay.rory.plural_contact_bot.system_data"); await ctx.Reply(MessageFormatter.FormatError($"System {sysName} already exists!")); } catch (MatrixException e) { diff --git a/ExampleBots/PluralContactBotPoC/Bot/PluralContactBot.cs b/ExampleBots/PluralContactBotPoC/Bot/PluralContactBot.cs index 0bd2bbf..231af95 100644 --- a/ExampleBots/PluralContactBotPoC/Bot/PluralContactBot.cs +++ b/ExampleBots/PluralContactBotPoC/Bot/PluralContactBot.cs @@ -38,14 +38,16 @@ public class PluralContactBot(AuthenticatedHomeserverGeneric hs, ILogger { + var syncHelper = new SyncHelper(hs); + + 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 {} by {} with reason: {}", args.Key, inviteEvent.Sender, (inviteEvent.TypedContent as RoomMemberEventContent).Reason); try { - var accountData = await hs.GetAccountData($"gay.rory.plural_contact_bot.system_data#{inviteEvent.StateKey}"); + var accountData = await hs.GetAccountDataAsync($"gay.rory.plural_contact_bot.system_data#{inviteEvent.StateKey}"); if (accountData.Members.Contains(inviteEvent.Sender)) { await (hs.GetRoom(args.Key)).JoinAsync(reason: "I was invited by a system member!"); @@ -74,7 +76,7 @@ public class PluralContactBot(AuthenticatedHomeserverGeneric hs, ILogger { + syncHelper.TimelineEventHandlers.Add(async @event => { var room = hs.GetRoom(@event.RoomId); try { logger.LogInformation( @@ -87,7 +89,7 @@ public class PluralContactBot(AuthenticatedHomeserverGeneric hs, ILogger