diff options
Diffstat (limited to 'MatrixContentFilter')
29 files changed, 1443 insertions, 0 deletions
diff --git a/MatrixContentFilter/Abstractions/IContentFilter.cs b/MatrixContentFilter/Abstractions/IContentFilter.cs new file mode 100644 index 0000000..108030b --- /dev/null +++ b/MatrixContentFilter/Abstractions/IContentFilter.cs @@ -0,0 +1,27 @@ +using System.Diagnostics; +using LibMatrix; +using LibMatrix.Responses; +using MatrixContentFilter.EventTypes; + +namespace MatrixContentFilter.Abstractions; + +public abstract class IContentFilter +{ + public virtual Task ProcessSyncAsync(SyncResponse syncEvent) { + var type = this.GetType().FullName; + Console.WriteLine($"WARNING: {type} does not implement ProcessSyncAsync(SyncResponse syncEvent)"); + if(Debugger.IsAttached) + Debugger.Break(); + return Task.CompletedTask; + } + + public virtual Task ProcessEventListAsync(List<StateEventResponse> events) { + var type = this.GetType().FullName; + Console.WriteLine($"WARNING: {type} does not implement ProcessEventListAsync(List<StateEventResponse> events)"); + if(Debugger.IsAttached) + Debugger.Break(); + return Task.CompletedTask; + } + + public int ActionCount { get; set; } = 0; +} \ No newline at end of file diff --git a/MatrixContentFilter/Commands/CheckHistoryCommand.cs b/MatrixContentFilter/Commands/CheckHistoryCommand.cs new file mode 100644 index 0000000..1e98545 --- /dev/null +++ b/MatrixContentFilter/Commands/CheckHistoryCommand.cs @@ -0,0 +1,74 @@ +using LibMatrix.Helpers; +using LibMatrix.Homeservers; +using LibMatrix.Utilities.Bot.Interfaces; +using MatrixContentFilter.Abstractions; +using MatrixContentFilter.Services; +using Microsoft.Extensions.Logging; + +namespace MatrixContentFilter.Commands; + +public class CheckHistoryCommand( + ConfigurationService filterConfigService, + IEnumerable<IContentFilter> filters, + AsyncMessageQueue msgQueue, + InfoCacheService infoCache +) : ICommand { + public string Name { get; } = "checkhistory"; + public string[]? Aliases { get; } = ["check"]; + public string Description { get; } = "Re-apply filters to last x messages (default: 100)"; + public bool Unlisted { get; } = false; + + public async Task Invoke(CommandContext ctx) { + var count = 100; + if (ctx.Args.Length > 0) { + if (!int.TryParse(ctx.Args[0], out count)) { + await ctx.Room.SendMessageEventAsync(new MessageBuilder("m.notice").WithBody($"'{count}' is not a valid number!").Build()); + return; + } + } + + msgQueue.EnqueueMessageAsync(filterConfigService.LogRoom, + new MessageBuilder("m.notice").WithBody($"Re-applying filters to last {count} messages in ") + .WithMention(ctx.Room.RoomId, await infoCache.GetRoomNameAsync(ctx.Room.RoomId)).Build()); + + await foreach (var resp in ctx.Room.GetManyMessagesAsync(limit: count, chunkSize: Math.Min(count, 250))) { + foreach (var filter in filters) { + await filter.ProcessEventListAsync(resp.Chunk); + } + } + } + + // /// <summary>Triggered when the application host is ready to start the service.</summary> + // /// <param name="cancellationToken">Indicates that the start process has been aborted.</param> + // protected override async Task ExecuteAsync(CancellationToken cancellationToken) { + // while (!cancellationToken.IsCancellationRequested) { + // await Task.Delay(10000, cancellationToken); + // var rooms = await hs.GetJoinedRooms(); + // rooms.RemoveAll(x => x.RoomId == filterConfigService.LogRoom.RoomId); + // rooms.RemoveAll(x => x.RoomId == filterConfigService.ControlRoom.RoomId); + // + // var timelineFilter = new SyncFilter.RoomFilter.StateFilter(notTypes: ["m.room.redaction"], limit: 5000); + // var timelines = rooms.Select(async x => { + // var room = hs.GetRoom(x.RoomId); + // // var sync = await room.GetMessagesAsync(null, 1500, filter: timelineFilter.ToJson(ignoreNull: true, indent: false).UrlEncode()); + // var iter = room.GetManyMessagesAsync(null, 5000, filter: timelineFilter.ToJson(ignoreNull: true, indent: false).UrlEncode(), chunkSize: 250); + // await foreach (var sync in iter) { + // var tasks = Parallel.ForEachAsync(filters, async (filter, ct) => { + // try { + // Console.WriteLine("Processing filter {0} (sanity check, chunk[s={1}])", filter.GetType().FullName, sync.Chunk.Count); + // await filter.ProcessEventListAsync(sync.Chunk); + // } + // catch (Exception e) { + // logger.LogError(e, "Error processing sync with filter {filter}", filter.GetType().FullName); + // msgQueue.EnqueueMessageAsync(filterConfigService.LogRoom, new MessageBuilder("m.notice") + // .WithBody($"Error processing sync with filter {filter.GetType().FullName}: {e.Message}").Build()); + // } + // }); + // + // await tasks; + // } + // }).ToList(); + // await Task.WhenAll(timelines); + // } + // } +} \ No newline at end of file diff --git a/MatrixContentFilter/Commands/ConfigureCommand.cs b/MatrixContentFilter/Commands/ConfigureCommand.cs new file mode 100644 index 0000000..756fc14 --- /dev/null +++ b/MatrixContentFilter/Commands/ConfigureCommand.cs @@ -0,0 +1,38 @@ +using System.Text; +using LibMatrix.EventTypes.Spec; +using LibMatrix.Utilities.Bot.Commands; +using LibMatrix.Utilities.Bot.Interfaces; +using Microsoft.Extensions.DependencyInjection; + +namespace MatrixContentFilter.Commands; + +public class ConfigureCommand(IServiceProvider svcs) : ICommandGroup { + public string Name { get; } = "configure"; + public string[]? Aliases { get; } = ["config", "cfg"]; + public string Description { get; } + public bool Unlisted { get; } = true; + + public async Task Invoke(CommandContext ctx) { + var commands = svcs.GetServices<ICommand>().Where(x => x.GetType().IsAssignableTo(typeof(ICommand<>).MakeGenericType(GetType()))).ToList(); + + if (ctx.Args.Length == 0) { + await ctx.Room.SendMessageEventAsync(HelpCommand.GenerateCommandList(commands).Build()); + } + else { + var subcommand = ctx.Args[0]; + var command = commands.FirstOrDefault(x => x.Name == subcommand || x.Aliases?.Contains(subcommand) == true); + if (command == null) { + await ctx.Room.SendMessageEventAsync(new RoomMessageEventContent("m.notice", "Unknown subcommand")); + return; + } + + await command.Invoke(new CommandContext { + Room = ctx.Room, + MessageEvent = ctx.MessageEvent, + CommandName = ctx.CommandName, + Args = ctx.Args.Skip(1).ToArray(), + Homeserver = ctx.Homeserver + }); + } + } +} \ No newline at end of file diff --git a/MatrixContentFilter/Commands/ConfigureSubCommands/ControlRoomConfigureSubcommand.cs b/MatrixContentFilter/Commands/ConfigureSubCommands/ControlRoomConfigureSubcommand.cs new file mode 100644 index 0000000..5ff4f9d --- /dev/null +++ b/MatrixContentFilter/Commands/ConfigureSubCommands/ControlRoomConfigureSubcommand.cs @@ -0,0 +1,18 @@ +using LibMatrix.Helpers; +using LibMatrix.Utilities.Bot.Interfaces; + +namespace MatrixContentFilter.Commands.ConfigureSubCommands; + +public class ControlRoomConfigureSubCommand : ICommand<ConfigureCommand> { + public string Name { get; } = "controlroom"; + public string[]? Aliases { get; } + public string Description { get; } = "Configure the control room"; + public bool Unlisted { get; } + + public async Task Invoke(CommandContext ctx) { + if (ctx.Args.Length == 0) { + await ctx.Room.SendMessageEventAsync(new MessageBuilder("m.notice").WithBody("meow").Build()); + } + + } +} \ No newline at end of file diff --git a/MatrixContentFilter/Commands/DumpEventCommand.cs b/MatrixContentFilter/Commands/DumpEventCommand.cs new file mode 100644 index 0000000..5131e19 --- /dev/null +++ b/MatrixContentFilter/Commands/DumpEventCommand.cs @@ -0,0 +1,31 @@ +using ArcaneLibs.Extensions; +using LibMatrix.EventTypes.Spec; +using LibMatrix.EventTypes.Spec.State; +using LibMatrix.Filters; +using LibMatrix.Helpers; +using LibMatrix.Homeservers; +using LibMatrix.Utilities.Bot.Interfaces; +using MatrixContentFilter.Abstractions; +using MatrixContentFilter.Services; +using MatrixContentFilter.Services.AsyncActionQueues; +using Microsoft.Extensions.Logging; + +namespace MatrixContentFilter.Commands; + +public class DumpEventCommand( + ConfigurationService filterConfigService, + AsyncMessageQueue msgQueue, + InfoCacheService infoCache, + ConfigurationService cfgService, + AbstractAsyncActionQueue actionQueue +) : ICommand { + public string Name { get; } = "dump"; + public string[]? Aliases { get; } = []; + public string Description { get; } = "Dump event by ID"; + public bool Unlisted { get; } = false; + + public async Task Invoke(CommandContext ctx) { + var evt = await ctx.Room.GetEventAsync(ctx.Args[0]); + await ctx.Room.SendMessageEventAsync(new MessageBuilder("m.notice").WithBody(evt.ToJson(ignoreNull: true)).Build()); + } +} \ No newline at end of file diff --git a/MatrixContentFilter/Commands/GetConfigCommand.cs b/MatrixContentFilter/Commands/GetConfigCommand.cs new file mode 100644 index 0000000..bac00ca --- /dev/null +++ b/MatrixContentFilter/Commands/GetConfigCommand.cs @@ -0,0 +1,56 @@ +using System.Text; +using ArcaneLibs.Attributes; +using ArcaneLibs.Extensions; +using LibMatrix.EventTypes.Spec; +using LibMatrix.Helpers; +using LibMatrix.Utilities.Bot.Commands; +using LibMatrix.Utilities.Bot.Interfaces; +using MatrixContentFilter.EventTypes; +using Microsoft.Extensions.DependencyInjection; + +namespace MatrixContentFilter.Commands; + +public class GetConfigCommand(IServiceProvider svcs) : ICommand { + public string Name { get; } = "getconfig"; + public string[]? Aliases { get; } = []; + public string Description { get; } = "Get the current configuration, optionally takes a room ID"; + public bool Unlisted { get; } = false; + + public async Task Invoke(CommandContext ctx) { + var room = ctx.Room; + if (ctx.Args.Length > 0) { + try { + room = ctx.Homeserver.GetRoom(ctx.Args[0]); + } + catch { + await ctx.Room.SendMessageEventAsync(new MessageBuilder("m.notice").WithBody("Invalid room ID").Build()); + return; + } + } + + var defaults = await ctx.Homeserver.GetAccountDataAsync<FilterConfiguration>(FilterConfiguration.EventId); + var config = await room.GetRoomAccountDataOrNullAsync<FilterConfiguration>(FilterConfiguration.EventId); + var msb = new MessageBuilder("m.notice").WithColoredBody("#FFCC00", "Default configuration:") + .WithTable(tb => { + foreach (var prop in defaults.GetType().GetProperties()) { + var key = prop.GetFriendlyName(); + var val = prop.GetValue(defaults); + + tb = tb.WithRow(rb => { + rb.WithCell(key); + rb.WithCell(val?.ToJson() ?? "null"); + }); + } + }); + + if (config == null) { + msb = msb.WithBody("No configuration set for this room, using defaults"); + } + else { + msb = msb.WithBody("Room overrides (additive):") + .WithCodeBlock(config.ToJson(ignoreNull: true), "json"); + } + + await room.SendMessageEventAsync(msb.Build()); + } +} \ No newline at end of file diff --git a/MatrixContentFilter/Commands/NewRoomCommand.cs b/MatrixContentFilter/Commands/NewRoomCommand.cs new file mode 100644 index 0000000..b8becd4 --- /dev/null +++ b/MatrixContentFilter/Commands/NewRoomCommand.cs @@ -0,0 +1,24 @@ +using System.Text; +using ArcaneLibs.Attributes; +using ArcaneLibs.Extensions; +using LibMatrix.EventTypes.Spec; +using LibMatrix.Helpers; +using LibMatrix.Utilities.Bot.Commands; +using LibMatrix.Utilities.Bot.Interfaces; +using MatrixContentFilter.EventTypes; +using Microsoft.Extensions.DependencyInjection; + +namespace MatrixContentFilter.Commands; + +public class NewRoomCommand(IServiceProvider svcs) : ICommand { + public string Name { get; } = "newroom"; + public string[]? Aliases { get; } = ["nr"]; + public string Description { get; } = "Create a new room"; + public bool Unlisted { get; } = false; + + public async Task Invoke(CommandContext ctx) { + await ctx.Homeserver.CreateRoom(new() { + Invite = [ctx.MessageEvent.Sender!] + }); + } +} \ No newline at end of file diff --git a/MatrixContentFilter/Commands/RedactCommand.cs b/MatrixContentFilter/Commands/RedactCommand.cs new file mode 100644 index 0000000..6b2f8b6 --- /dev/null +++ b/MatrixContentFilter/Commands/RedactCommand.cs @@ -0,0 +1,99 @@ +using ArcaneLibs.Extensions; +using LibMatrix.EventTypes.Spec; +using LibMatrix.EventTypes.Spec.State; +using LibMatrix.Filters; +using LibMatrix.Helpers; +using LibMatrix.Homeservers; +using LibMatrix.Utilities.Bot.Interfaces; +using MatrixContentFilter.Abstractions; +using MatrixContentFilter.Services; +using MatrixContentFilter.Services.AsyncActionQueues; +using Microsoft.Extensions.Logging; + +namespace MatrixContentFilter.Commands; + +public class RedactCommand( + ConfigurationService filterConfigService, + AsyncMessageQueue msgQueue, + InfoCacheService infoCache, + ConfigurationService cfgService, + AbstractAsyncActionQueue actionQueue +) : ICommand { + public string Name { get; } = "redact"; + public string[]? Aliases { get; } = []; + public string Description { get; } = "Redact last x messages from user (default: 500)"; + public bool Unlisted { get; } = false; + + public async Task Invoke(CommandContext ctx) { + var count = 500; + + if (ctx.Args.Length == 0) { + await ctx.Room.SendMessageEventAsync(new MessageBuilder("m.notice") + .WithBody("Please provide a user ID to redact messages from. (Make sure that it isn't formatted! Do not autocomplete!)").Build()); + return; + } + + var mxid = ctx.Args[0]; + if (ctx.Args.Length > 1) { + if (!int.TryParse(ctx.Args[1], out count)) { + await ctx.Room.SendMessageEventAsync(new MessageBuilder("m.notice").WithBody($"'{count}' is not a valid number!").Build()); + return; + } + } + + var displayName = await infoCache.GetDisplayNameAsync(ctx.Room.RoomId, ctx.MessageEvent.Sender); + var roomName = await infoCache.GetRoomNameAsync(ctx.Room.RoomId); + + msgQueue.EnqueueMessageAsync(filterConfigService.LogRoom, + new MessageBuilder("m.notice").WithBody($"Removing last {count} messages from ").WithMention(mxid) + .WithBody(" in ").WithMention(ctx.Room.RoomId, await infoCache.GetRoomNameAsync(ctx.Room.RoomId)).Build()); + var hourglassReaction = await ctx.Room.SendTimelineEventAsync("m.reaction", new RoomMessageReactionEventContent() { + RelatesTo = new() { + EventId = ctx.MessageEvent.EventId, + RelationType = "m.annotation", + Key = "\u23f3" //hour glass emoji + } + }); + + await foreach (var resp in ctx.Room.GetManyMessagesAsync(limit: count, chunkSize: Math.Min(count, 250) + ,filter: new SyncFilter.RoomFilter.StateFilter(types: [RoomMemberEventContent.EventId, RoomMessageEventContent.EventId], senders: [mxid]) + .ToJson(indent: false, ignoreNull: true).UrlEncode()) + ) { + foreach (var msg in resp.Chunk) { + if (msg.Sender != mxid) continue; + if (msg is not { Type: RoomMemberEventContent.EventId or RoomMessageEventContent.EventId }) continue; + if (msg.RawContent is not { Count: > 0 }) continue; + + await actionQueue.EqueueActionAsync(msg.EventId, async () => { + while (true) { + try { + await ctx.Room.RedactEventAsync(msg.EventId ?? throw new ArgumentException("Event ID is null?"), "Message removed by moderator."); + break; + } + catch (Exception e) { + msgQueue.EnqueueMessageAsync(cfgService.LogRoom, new MessageBuilder("m.notice") + .WithBody($"Error redacting message in {ctx.Room.RoomId}!") + .WithCollapsibleSection("Error data", msb => msb.WithCodeBlock(e.ToString(), "csharp")) + .Build()); + } + } + + msgQueue.EnqueueMessageAsync(cfgService.LogRoom, new MessageBuilder("m.notice") + .WithBody($"Message sent by ").WithMention(msg.Sender, displayName).WithBody(" in ").WithMention(ctx.Room.RoomId, roomName) + .WithBody(" was removed in request by ").WithMention(ctx.Room.RoomId, roomName).WithBody("!").WithNewline() + .WithCollapsibleSection("Message data", msb => msb.WithCodeBlock(msg.RawContent.ToJson(ignoreNull: true), "json")) + .Build()); + }); + } + + await ctx.Room.RedactEventAsync(hourglassReaction.EventId); + await ctx.Room.SendTimelineEventAsync("m.reaction", new RoomMessageReactionEventContent() { + RelatesTo = new() { + EventId = ctx.MessageEvent.EventId, + RelationType = "m.annotation", + Key = "\u2714\ufe0f" //check mark emoji + } + }); + } + } +} \ No newline at end of file diff --git a/MatrixContentFilter/EventTypes/BotEnvironmentConfiguration.cs b/MatrixContentFilter/EventTypes/BotEnvironmentConfiguration.cs new file mode 100644 index 0000000..7599132 --- /dev/null +++ b/MatrixContentFilter/EventTypes/BotEnvironmentConfiguration.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; +using LibMatrix.EventTypes; + +namespace MatrixContentFilter.EventTypes; + + +[MatrixEvent(EventName = EventId)] +public class BotEnvironmentConfiguration : EventContent { + public const string EventId = "gay.rory.MatrixContentFilterBot.environment"; + + [JsonPropertyName("log_room_id")] + public string? LogRoomId { get; set; } + + [JsonPropertyName("control_room_id")] + public string? ControlRoomId { get; set; } + +} \ No newline at end of file diff --git a/MatrixContentFilter/EventTypes/FilterConfiguration.cs b/MatrixContentFilter/EventTypes/FilterConfiguration.cs new file mode 100644 index 0000000..8e3b900 --- /dev/null +++ b/MatrixContentFilter/EventTypes/FilterConfiguration.cs @@ -0,0 +1,43 @@ +using System.Text.Json.Serialization; +using ArcaneLibs.Attributes; +using LibMatrix.EventTypes; + +namespace MatrixContentFilter.EventTypes; + +[MatrixEvent(EventName = EventId)] +public class FilterConfiguration : EventContent { + public const string EventId = "gay.rory.MatrixContentFilterBot.filter_configuration"; + + [JsonPropertyName("image_filter")] + [FriendlyName(Name = "Images")] + public BasicFilterConfiguration? ImageFilter { get; set; } + + [JsonPropertyName("video_filter")] + [FriendlyName(Name = "Videos")] + public BasicFilterConfiguration? VideoFilter { get; set; } + + [JsonPropertyName("audio_filter")] + [FriendlyName(Name = "Audio")] + public BasicFilterConfiguration? AudioFilter { get; set; } + + [JsonPropertyName("file_filter")] + [FriendlyName(Name = "Files")] + public BasicFilterConfiguration? FileFilter { get; set; } + + [JsonPropertyName("url_filter")] + [FriendlyName(Name = "Links")] + public BasicFilterConfiguration? UrlFilter { get; set; } + + [JsonPropertyName("ignored_users")] + [FriendlyName(Name = "Ignored Users")] + public List<string>? IgnoredUsers { get; set; } + + + public class BasicFilterConfiguration { + [JsonPropertyName("allowed")] + public bool? Allowed { get; set; } + + [JsonPropertyName("ignored_users")] + public List<string>? IgnoredUsers { get; set; } + } +} \ No newline at end of file diff --git a/MatrixContentFilter/Handlers/CommandResultHandler.cs b/MatrixContentFilter/Handlers/CommandResultHandler.cs new file mode 100644 index 0000000..f05d2bb --- /dev/null +++ b/MatrixContentFilter/Handlers/CommandResultHandler.cs @@ -0,0 +1,40 @@ +using ArcaneLibs; +using LibMatrix.Helpers; +using LibMatrix.Utilities.Bot.Interfaces; + +namespace MatrixContentFilter.Handlers; + +public static class CommandResultHandler { + private static string binDir = FileUtils.GetBinDir(); + + public static async Task HandleAsync(CommandResult res) { + { + if (res.Success) return; + var room = res.Context.Room; + var hs = res.Context.Homeserver; + var msb = new MessageBuilder(); + if (res.Result == CommandResult.CommandResultType.Failure_Exception) { + var angryEmojiPath = Path.Combine(binDir, "Resources", "Stickers", "JennyAngryPink.webp"); + var hash = await FileUtils.GetFileSha384Async(angryEmojiPath); + var angryEmoji = await hs.NamedCaches.FileCache.GetOrSetValueAsync(hash, async () => { + await using var fs = File.OpenRead(angryEmojiPath); + return await hs.UploadFile("JennyAngryPink.webp", fs, "image/webp"); + }); + msb.WithCustomEmoji(angryEmoji, "JennyAngryPink") + .WithColoredBody("#EE4444", "An error occurred during the execution of this command") + .WithCodeBlock(res.Exception!.ToString(), "csharp"); + } + // else if(res.Result == CommandResult.CommandResultType.) { + // msb.AddMessage(new RoomMessageEventContent("m.notice", "An error occurred during the execution of this command")); + // } + // var msg = res.Result switch { + // CommandResult.CommandResultType.Failure_Exception => MessageFormatter.FormatException("An error occurred during the execution of this command", res.Exception!) + // CommandResult.CommandResultType.Failure_NoPermission => new RoomMessageEventContent("m.notice", "You do not have permission to run this command!"), + // CommandResult.CommandResultType.Failure_InvalidCommand => new RoomMessageEventContent("m.notice", $"Command \"{res.Context.CommandName}\" not found!"), + // _ => throw new ArgumentOutOfRangeException() + // }; + + await room.SendMessageEventAsync(msb.Build()); + } + } +} \ No newline at end of file diff --git a/MatrixContentFilter/Handlers/Filters/ImageFilter.cs b/MatrixContentFilter/Handlers/Filters/ImageFilter.cs new file mode 100644 index 0000000..13a68f9 --- /dev/null +++ b/MatrixContentFilter/Handlers/Filters/ImageFilter.cs @@ -0,0 +1,92 @@ +using System.Runtime.Loader; +using System.Security.Cryptography; +using System.Text.Json.Nodes; +using ArcaneLibs.Collections; +using ArcaneLibs.Extensions; +using LibMatrix; +using LibMatrix.EventTypes.Spec; +using LibMatrix.Helpers; +using LibMatrix.Homeservers; +using LibMatrix.Responses; +using LibMatrix.RoomTypes; +using MatrixContentFilter.Abstractions; +using MatrixContentFilter.EventTypes; +using MatrixContentFilter.Services; +using MatrixContentFilter.Services.AsyncActionQueues; + +namespace MatrixContentFilter.Handlers.Filters; + +public class ImageFilter( + ConfigurationService cfgService, + AuthenticatedHomeserverGeneric hs, + AsyncMessageQueue msgQueue, + InfoCacheService infoCache, + AbstractAsyncActionQueue actionQueue) + : IContentFilter { + public override async Task ProcessSyncAsync(SyncResponse syncResponse) { + Console.WriteLine("Processing image filter"); + if (syncResponse.Rooms?.Join is null) return; + var tasks = syncResponse.Rooms.Join.Select(ProcessRoomAsync); + await Task.WhenAll(tasks); + } + + // private SemaphoreSlim semaphore = new(8, 8); + + private async Task ProcessRoomAsync(KeyValuePair<string, SyncResponse.RoomsDataStructure.JoinedRoomDataStructure> syncRoom) { + var (roomId, roomData) = syncRoom; + if (roomId == cfgService.LogRoom.RoomId || roomId == cfgService.ControlRoom.RoomId) return; + if (roomData.Timeline?.Events is null) return; + var config = cfgService.RoomConfigurationOverrides.GetValueOrDefault(roomId)?.ImageFilter; + + var room = hs.GetRoom(roomId); + + var tasks = roomData.Timeline.Events.Select(msg => ProcessEventAsync(room, msg, config)); + await Task.WhenAll(tasks); + } + + public override async Task ProcessEventListAsync(List<StateEventResponse> events) { + var tasks = events.GroupBy(x => x.RoomId).Select(async x => { + var room = hs.GetRoom(x.Key); + var config = cfgService.RoomConfigurationOverrides.GetValueOrDefault(x.Key)?.ImageFilter; + var tasks = x.Select(msg => ProcessEventAsync(room, msg, config)); + await Task.WhenAll(tasks); + }); + + await Task.WhenAll(tasks); + } + + private async Task ProcessEventAsync(GenericRoom room, StateEventResponse msg, FilterConfiguration.BasicFilterConfiguration roomConfiguration) { + if (msg.Type != "m.room.message") return; + var content = msg.TypedContent as RoomMessageEventContent; + if (content?.MessageType != "m.image") return; + + // await semaphore.WaitAsync(); + + await actionQueue.EqueueActionAsync(msg.EventId, async () => { + while (true) { + try { + Console.WriteLine("Redacting image message: {0}", msg.EventId); + await room.RedactEventAsync(msg.EventId ?? throw new ArgumentException("Event ID is null?"), "Not allowed to send images in this room!"); + break; + } + catch (Exception e) { + msgQueue.EnqueueMessageAsync(cfgService.LogRoom, new MessageBuilder("m.notice") + .WithBody($"Error redacting image message in {room.RoomId}!") + .WithCollapsibleSection("Error data", msb => msb.WithCodeBlock(e.ToString(), "csharp")) + .Build()); + } + } + + var displayName = await infoCache.GetDisplayNameAsync(room.RoomId, msg.Sender); + var roomName = await infoCache.GetRoomNameAsync(room.RoomId); + + msgQueue.EnqueueMessageAsync(cfgService.LogRoom, new MessageBuilder("m.notice") + .WithBody($"Image sent by ").WithMention(msg.Sender, displayName).WithBody(" in ").WithMention(room.RoomId, roomName).WithBody(" was removed!").WithNewline() + .WithCollapsibleSection("Message data", msb => msb.WithCodeBlock(content.ToJson(ignoreNull: true), "json")) + .Build()); + }); + ActionCount++; + + // semaphore.Release(); + } +} \ No newline at end of file diff --git a/MatrixContentFilter/Handlers/InviteHandler.cs b/MatrixContentFilter/Handlers/InviteHandler.cs new file mode 100644 index 0000000..75e5506 --- /dev/null +++ b/MatrixContentFilter/Handlers/InviteHandler.cs @@ -0,0 +1,29 @@ +using LibMatrix.EventTypes.Spec; +using LibMatrix.Helpers; +using LibMatrix.Utilities.Bot.Services; + +namespace MatrixContentFilter.Handlers; + +public static class InviteHandler { + public static async Task HandleAsync(InviteHandlerHostedService.InviteEventArgs invite) { + var room = invite.Homeserver.GetRoom(invite.RoomId); + if (!invite.MemberEvent.Sender!.EndsWith("rory.gay")) { + await room.LeaveAsync($"{invite.MemberEvent.Sender} is not allowed to invite this bot!"); + return; + } + + try { + await room.JoinAsync(reason: $"I was invited by {invite.MemberEvent.Sender}"); + await room.SendMessageEventAsync(new RoomMessageEventContent("m.notice", "Hello! I've arrived!")); + } catch (Exception e) { + var newroom = await invite.Homeserver.CreateRoom(new() { + Name = $"Join error report", + Invite = [invite.MemberEvent.Sender] + }); + var msb = new MessageBuilder(); + msb.WithColoredBody("#EE4444", $"An error occurred during accepting the invite to {invite.RoomId}") + .WithCodeBlock(e.ToString(), "csharp"); + await newroom.SendMessageEventAsync(msb.Build()); + } + } +} \ No newline at end of file diff --git a/MatrixContentFilter/MatrixContentFilter.csproj b/MatrixContentFilter/MatrixContentFilter.csproj new file mode 100644 index 0000000..ea0ddd1 --- /dev/null +++ b/MatrixContentFilter/MatrixContentFilter.csproj @@ -0,0 +1,39 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <OutputType>Exe</OutputType> + <TargetFramework>net8.0</TargetFramework> + <LangVersion>preview</LangVersion> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <PublishAot>false</PublishAot> + <InvariantGlobalization>true</InvariantGlobalization> + <!-- <PublishTrimmed>true</PublishTrimmed>--> + <!-- <PublishReadyToRun>true</PublishReadyToRun>--> + <!-- <PublishSingleFile>true</PublishSingleFile>--> + <!-- <PublishReadyToRunShowWarnings>true</PublishReadyToRunShowWarnings>--> + <!-- <PublishTrimmedShowLinkerSizeComparison>true</PublishTrimmedShowLinkerSizeComparison>--> + <!-- <PublishTrimmedShowLinkerSizeComparisonWarnings>true</PublishTrimmedShowLinkerSizeComparisonWarnings>--> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\LibMatrix\LibMatrix\LibMatrix.csproj" /> + <ProjectReference Include="..\LibMatrix\Utilities\LibMatrix.Utilities.Bot\LibMatrix.Utilities.Bot.csproj" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" /> + </ItemGroup> + <ItemGroup> + <Content Include="appsettings*.json"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </Content> + <Content Include="Resources\**\*"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <Content Remove="Resources\.git\**\*"/> + </ItemGroup> + <ItemGroup> + <Folder Include="Utilities\" /> + </ItemGroup> +</Project> diff --git a/MatrixContentFilter/MatrixContentFilterConfiguration.cs b/MatrixContentFilter/MatrixContentFilterConfiguration.cs new file mode 100644 index 0000000..57537f0 --- /dev/null +++ b/MatrixContentFilter/MatrixContentFilterConfiguration.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Configuration; + +namespace MatrixContentFilter; + +public class MatrixContentFilterConfiguration { + public MatrixContentFilterConfiguration(IConfiguration config) => config.GetRequiredSection("MatrixContentFilter").Bind(this); + + public List<string> Admins { get; set; } = new(); + public ConcurrencyLimitsConfiguration ConcurrencyLimits { get; set; } = new(); + + public string AppMode { get; set; } = "bot"; + public string AsyncQueueImplementation { get; set; } = "lifo"; + + public class ConcurrencyLimitsConfiguration { + public int Redactions { get; set; } = 1; + public int LogMessages { get; set; } = 1; + } +} diff --git a/MatrixContentFilter/Program.cs b/MatrixContentFilter/Program.cs new file mode 100644 index 0000000..7eec930 --- /dev/null +++ b/MatrixContentFilter/Program.cs @@ -0,0 +1,70 @@ +using MatrixContentFilter; +using MatrixContentFilter.Handlers; +using LibMatrix.Services; +using LibMatrix.Utilities.Bot; +using MatrixContentFilter.Abstractions; +using MatrixContentFilter.Handlers.Filters; +using MatrixContentFilter.Services; +using MatrixContentFilter.Services.AsyncActionQueues; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +var builder = Host.CreateDefaultBuilder(args); + +builder.ConfigureHostOptions(host => { + host.ServicesStartConcurrently = true; + host.ServicesStopConcurrently = true; + host.ShutdownTimeout = TimeSpan.FromSeconds(5); +}); + +if (Environment.GetEnvironmentVariable("MATRIXCONTENTFILTER_APPSETTINGS_PATH") is string path) + builder.ConfigureAppConfiguration(x => x.AddJsonFile(path)); + +var host = builder.ConfigureServices((ctx, services) => { + var config = new MatrixContentFilterConfiguration(ctx.Configuration); + services.AddSingleton<MatrixContentFilterConfiguration>(config); + + services.AddRoryLibMatrixServices(new() { + AppName = "MatrixContentFilter" + }); + + services.AddMatrixBot().AddCommandHandler().DiscoverAllCommands() + .WithInviteHandler(InviteHandler.HandleAsync) + .WithCommandResultHandler(CommandResultHandler.HandleAsync); + + services.AddSingleton<InfoCacheService>(); + + services.AddSingleton<IContentFilter, ImageFilter>(); + + services.AddSingleton<ConfigurationService>(); + services.AddSingleton<IHostedService, ConfigurationService>(s => s.GetRequiredService<ConfigurationService>()); + services.AddSingleton<AsyncMessageQueue>(); + services.AddSingleton<IHostedService, AsyncMessageQueue>(s => s.GetRequiredService<AsyncMessageQueue>()); + + switch (config.AppMode) { + case "bot": + services.AddHostedService<MatrixContentFilterBot>(); + // services.AddHostedService<BotModeSanityCheckService>(); + break; + default: + throw new NotSupportedException($"Unknown app mode: {config.AppMode}"); + } + + switch (config.AsyncQueueImplementation) { + case "lifo": + services.AddSingleton<LiFoAsyncActionQueue>(); + services.AddSingleton<AbstractAsyncActionQueue, LiFoAsyncActionQueue>(s => s.GetRequiredService<LiFoAsyncActionQueue>()); + services.AddSingleton<IHostedService, LiFoAsyncActionQueue>(s => s.GetRequiredService<LiFoAsyncActionQueue>()); + break; + case "fifo": + services.AddSingleton<FiFoAsyncActionQueue>(); + services.AddSingleton<AbstractAsyncActionQueue, FiFoAsyncActionQueue>(s => s.GetRequiredService<FiFoAsyncActionQueue>()); + services.AddSingleton<IHostedService, FiFoAsyncActionQueue>(s => s.GetRequiredService<FiFoAsyncActionQueue>()); + break; + default: + throw new NotSupportedException($"Unknown async queue implementation: {config.AsyncQueueImplementation}"); + } +}).UseConsoleLifetime().Build(); + +await host.RunAsync(); \ No newline at end of file diff --git a/MatrixContentFilter/Properties/launchSettings.json b/MatrixContentFilter/Properties/launchSettings.json new file mode 100644 index 0000000..997e294 --- /dev/null +++ b/MatrixContentFilter/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/MatrixContentFilter/Resources b/MatrixContentFilter/Resources new file mode 160000 +Subproject 46edc2287e1cc9009a9a72447a6603e959a8971 diff --git a/MatrixContentFilter/Services/AsyncActionQueues/AbstractionAsyncActionQueue.cs b/MatrixContentFilter/Services/AsyncActionQueues/AbstractionAsyncActionQueue.cs new file mode 100644 index 0000000..f4c559c --- /dev/null +++ b/MatrixContentFilter/Services/AsyncActionQueues/AbstractionAsyncActionQueue.cs @@ -0,0 +1,38 @@ +using System.Collections.Concurrent; +using System.Threading.Channels; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace MatrixContentFilter.Services.AsyncActionQueues; + +public abstract class AbstractAsyncActionQueue : BackgroundService { + private readonly ConcurrentStack<string> _recentIds = new(); + private readonly Channel<Func<Task>> _queue = Channel.CreateUnbounded<Func<Task>>(new UnboundedChannelOptions() { + SingleReader = true + }); + private static CancellationTokenSource _cts = new(); + + /// <summary> + /// Enqueue an action to be executed asynchronously + /// </summary> + /// <param name="id">Reproducible ID</param> + /// <param name="action">Action to execute</param> + /// <returns>`true` if action was appended, `false` if action was not added, eg. due to duplicate ID</returns> + public virtual async Task<bool> EqueueActionAsync(string id, Func<Task> action) { + throw new NotImplementedException(); + } + + private async Task ProcessQueue() { + throw new NotImplementedException(); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + while (!stoppingToken.IsCancellationRequested) { + await ProcessQueue(); + Console.WriteLine("AbstractAsyncActionQueue waiting for new actions, this should never happen!"); + } + + //clear backlog and exit + await ProcessQueue(); + } +} \ No newline at end of file diff --git a/MatrixContentFilter/Services/AsyncActionQueues/FiFoAsyncActionQueue.cs b/MatrixContentFilter/Services/AsyncActionQueues/FiFoAsyncActionQueue.cs new file mode 100644 index 0000000..3d7c90d --- /dev/null +++ b/MatrixContentFilter/Services/AsyncActionQueues/FiFoAsyncActionQueue.cs @@ -0,0 +1,60 @@ +using System.Collections.Concurrent; +using System.Threading.Channels; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace MatrixContentFilter.Services.AsyncActionQueues; + +public class FiFoAsyncActionQueue(ILogger<FiFoAsyncActionQueue> logger, MatrixContentFilterConfiguration cfg) : AbstractAsyncActionQueue { + // private readonly ConcurrentQueue<(string Id, Func<Task> Action)> _queue = new(); + private readonly HashSet<string> _recentIds = new(); + private readonly Channel<Func<Task>> _queue = Channel.CreateUnbounded<Func<Task>>(new UnboundedChannelOptions() { + SingleReader = true + }); + private readonly SemaphoreSlim _semaphore = new(cfg.ConcurrencyLimits.Redactions, cfg.ConcurrencyLimits.Redactions); + + /// <summary> + /// Enqueue an action to be executed asynchronously + /// </summary> + /// <param name="id">Reproducible ID</param> + /// <param name="action">Action to execute</param> + /// <returns>`true` if action was appended, `false` if action was not added, eg. due to duplicate ID</returns> + public override async Task<bool> EqueueActionAsync(string id, Func<Task> action) { + if (_recentIds.Contains(id)) { + logger.LogWarning("Duplicate action ID detected, ignoring action"); + return false; + } + await _queue.Writer.WriteAsync(action); + _recentIds.Add(id); + + if (_queue.Reader.Count > 100) { + logger.LogWarning("Action Queue is getting full, consider increasing the rate limit or exempting the bot!"); + } + + return true; + } + + private async Task ProcessQueue() { + await foreach (var task in _queue.Reader.ReadAllAsync()) { + await _semaphore.WaitAsync(); + _ = Task.Run(async () => { + try { + await task.Invoke(); + } + finally { + _semaphore.Release(); + } + }); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + while (!stoppingToken.IsCancellationRequested) { + await ProcessQueue(); + logger.LogWarning("Waiting for new actions, ProcessQueue returned early!"); + } + + //clear backlog and exit + await ProcessQueue(); + } +} \ No newline at end of file diff --git a/MatrixContentFilter/Services/AsyncActionQueues/LiFoAsyncActionQueue.cs b/MatrixContentFilter/Services/AsyncActionQueues/LiFoAsyncActionQueue.cs new file mode 100644 index 0000000..631cc74 --- /dev/null +++ b/MatrixContentFilter/Services/AsyncActionQueues/LiFoAsyncActionQueue.cs @@ -0,0 +1,67 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; + +namespace MatrixContentFilter.Services.AsyncActionQueues; + +public class LiFoAsyncActionQueue(ILogger<LiFoAsyncActionQueue> logger, MatrixContentFilterConfiguration cfg) : AbstractAsyncActionQueue { + // private readonly ConcurrentQueue<(string Id, Func<Task> Action)> _queue = new(); + private readonly HashSet<string> _recentIds = new(); + private readonly ConcurrentStack<(string Id, Func<Task> Action)> _queue = new(); + private static CancellationTokenSource _cts = new(); + private readonly SemaphoreSlim _semaphore = new(cfg.ConcurrencyLimits.Redactions, cfg.ConcurrencyLimits.Redactions); + + /// <summary> + /// Enqueue an action to be executed asynchronously + /// </summary> + /// <param name="id">Reproducible ID</param> + /// <param name="action">Action to execute</param> + /// <returns>`true` if action was appended, `false` if action was not added, eg. due to duplicate ID</returns> + public override async Task<bool> EqueueActionAsync(string id, Func<Task> action) { + if (_recentIds.Contains(id)) { + logger.LogWarning("Duplicate action ID detected, ignoring action"); + return false; + } + + _queue.Push((id, action)); + _recentIds.Add(id); + _cts.Cancel(false); + + return true; + } + + private async Task ProcessQueue() { + // await foreach (var task in _queue2.Reader.ReadAllAsync()) { + while (_queue.TryPop(out var task)) { + await _semaphore.WaitAsync(); + _ = Task.Run(async () => { + try { + await task.Action.Invoke(); + _recentIds.Remove(task.Id); + } + finally { + _semaphore.Release(); + } + }); + } + + _cts.Dispose(); + _cts = new CancellationTokenSource(); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + while (!stoppingToken.IsCancellationRequested) { + await ProcessQueue(); + Console.WriteLine(GetType().Name + " waiting for new actions"); + try { + await Task.Delay(10000, _cts.Token); + } + catch (TaskCanceledException) { + Console.WriteLine(GetType().Name + " _cts cancelled"); + // ignore + } + } + + //clear backlog and exit + await ProcessQueue(); + } +} \ No newline at end of file diff --git a/MatrixContentFilter/Services/AsyncMessageQueue.cs b/MatrixContentFilter/Services/AsyncMessageQueue.cs new file mode 100644 index 0000000..7912cc5 --- /dev/null +++ b/MatrixContentFilter/Services/AsyncMessageQueue.cs @@ -0,0 +1,43 @@ +using System.Collections.Concurrent; +using LibMatrix.EventTypes.Spec; +using LibMatrix.RoomTypes; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace MatrixContentFilter.Services; + +public class AsyncMessageQueue(ILogger<AsyncMessageQueue> logger, MatrixContentFilterConfiguration cfg) : BackgroundService { + private readonly ConcurrentQueue<(GenericRoom Room, RoomMessageEventContent Content)> _queue = new(); + private readonly SemaphoreSlim _semaphore = new(cfg.ConcurrencyLimits.LogMessages, cfg.ConcurrencyLimits.LogMessages); + public void EnqueueMessageAsync(GenericRoom room, RoomMessageEventContent content) { + _queue.Enqueue((room, content)); + + if (_queue.Count > 100) { + logger.LogWarning($"Message Queue is getting full (c={_queue.Count}), consider increasing the rate limit or exempting the bot!"); + } + } + + private async Task ProcessQueue() { + while (_queue.TryDequeue(out var message)) { + await _semaphore.WaitAsync(); + _ = Task.Run(async () => { + try { + await message.Room.SendMessageEventAsync(message.Content); + } + finally { + _semaphore.Release(); + } + }); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + while (!stoppingToken.IsCancellationRequested) { + await ProcessQueue(); + await Task.Delay(1000, stoppingToken); + } + + //clear backlog and exit + await ProcessQueue(); + } +} \ No newline at end of file diff --git a/MatrixContentFilter/Services/BotModeSanityCheckService.cs b/MatrixContentFilter/Services/BotModeSanityCheckService.cs new file mode 100644 index 0000000..55fe9e8 --- /dev/null +++ b/MatrixContentFilter/Services/BotModeSanityCheckService.cs @@ -0,0 +1,56 @@ +using System.Diagnostics; +using ArcaneLibs; +using ArcaneLibs.Extensions; +using LibMatrix; +using LibMatrix.Filters; +using LibMatrix.Helpers; +using LibMatrix.Homeservers; +using LibMatrix.Responses; +using MatrixContentFilter.Abstractions; +using MatrixContentFilter.Handlers.Filters; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace MatrixContentFilter.Services; + +public class BotModeSanityCheckService( + ILogger<BotModeSanityCheckService> logger, + AuthenticatedHomeserverGeneric hs, + ConfigurationService filterConfigService, + IEnumerable<IContentFilter> filters, + AsyncMessageQueue msgQueue +) : BackgroundService { + /// <summary>Triggered when the application host is ready to start the service.</summary> + /// <param name="cancellationToken">Indicates that the start process has been aborted.</param> + protected override async Task ExecuteAsync(CancellationToken cancellationToken) { + while (!cancellationToken.IsCancellationRequested) { + await Task.Delay(10000, cancellationToken); + var rooms = await hs.GetJoinedRooms(); + rooms.RemoveAll(x => x.RoomId == filterConfigService.LogRoom.RoomId); + rooms.RemoveAll(x => x.RoomId == filterConfigService.ControlRoom.RoomId); + + var timelineFilter = new SyncFilter.RoomFilter.StateFilter(notTypes: ["m.room.redaction"], limit: 5000); + var timelines = rooms.Select(async x => { + var room = hs.GetRoom(x.RoomId); + // var sync = await room.GetMessagesAsync(null, 1500, filter: timelineFilter.ToJson(ignoreNull: true, indent: false).UrlEncode()); + var iter = room.GetManyMessagesAsync(null, 5000, filter: timelineFilter.ToJson(ignoreNull: true, indent: false).UrlEncode(), chunkSize: 250); + await foreach (var sync in iter) { + var tasks = Parallel.ForEachAsync(filters, async (filter, ct) => { + try { + Console.WriteLine("Processing filter {0} (sanity check, chunk[s={1}])", filter.GetType().FullName, sync.Chunk.Count); + await filter.ProcessEventListAsync(sync.Chunk); + } + catch (Exception e) { + logger.LogError(e, "Error processing sync with filter {filter}", filter.GetType().FullName); + msgQueue.EnqueueMessageAsync(filterConfigService.LogRoom, new MessageBuilder("m.notice") + .WithBody($"Error processing sync with filter {filter.GetType().FullName}: {e.Message}").Build()); + } + }); + + await tasks; + } + }).ToList(); + await Task.WhenAll(timelines); + } + } +} \ No newline at end of file diff --git a/MatrixContentFilter/Services/ConfigurationService.cs b/MatrixContentFilter/Services/ConfigurationService.cs new file mode 100644 index 0000000..f83c89a --- /dev/null +++ b/MatrixContentFilter/Services/ConfigurationService.cs @@ -0,0 +1,198 @@ +using ArcaneLibs.Extensions; +using LibMatrix; +using LibMatrix.EventTypes.Spec.State; +using LibMatrix.Helpers; +using LibMatrix.Homeservers; +using LibMatrix.Responses; +using LibMatrix.RoomTypes; +using LibMatrix.Utilities; +using MatrixContentFilter.EventTypes; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace MatrixContentFilter.Services; + +public class ConfigurationService(ILogger<ConfigurationService> logger, AuthenticatedHomeserverGeneric hs, AsyncMessageQueue msgQueue) : BackgroundService { + public BotEnvironmentConfiguration EnvironmentConfiguration { get; private set; } + public FilterConfiguration DefaultConfiguration { get; private set; } + public Dictionary<string, FilterConfiguration> RoomConfigurationOverrides { get; } = new(); + public Dictionary<string, FilterConfiguration> FinalRoomConfigurations { get; } = new(); + + public GenericRoom LogRoom { get; private set; } = null!; + public GenericRoom ControlRoom { get; private set; } = null!; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + var syncHelper = new SyncHelper(hs, logger) { + NamedFilterName = CommonSyncFilters.GetAccountDataWithRooms + }; + + await foreach (var sync in syncHelper.EnumerateSyncAsync(stoppingToken).WithCancellation(stoppingToken)) { + if (sync is { AccountData: null, Rooms: null }) continue; + logger.LogInformation("Received configuration update: {syncData}", sync.ToJson(ignoreNull: true)); + await OnSyncReceived(sync); + } + } + + public async Task OnSyncReceived(SyncResponse sync) { + if (sync.AccountData?.Events?.FirstOrDefault(x => x.Type == BotEnvironmentConfiguration.EventId) is { } envEvent) { + EnvironmentConfiguration = envEvent.TypedContent as BotEnvironmentConfiguration; + msgQueue.EnqueueMessageAsync(LogRoom, new MessageBuilder("m.notice") + .WithColoredBody("#FF0088", "Environment configuration updated from sync.").WithNewline() + .WithCollapsibleSection("JSON data:", msb => msb.WithCodeBlock(EnvironmentConfiguration.ToJson(), "json")) + // .WithCollapsibleSection("Full event JSON", _msb => _msb.WithCodeBlock(envEvent.ToJson(), "json")) + .Build()); + LogRoom = hs.GetRoom(EnvironmentConfiguration.LogRoomId!); + ControlRoom = hs.GetRoom(EnvironmentConfiguration.ControlRoomId!); + } + + if (sync.AccountData?.Events?.FirstOrDefault(x => x.Type == FilterConfiguration.EventId) is { } filterEvent) { + DefaultConfiguration = filterEvent.TypedContent as FilterConfiguration; + msgQueue.EnqueueMessageAsync(LogRoom, new MessageBuilder("m.notice") + .WithColoredBody("#00FF88", "Default filter configuration updated from sync.").WithNewline() + .WithCollapsibleSection("JSON data:", msb => msb.WithCodeBlock(DefaultConfiguration.ToJson(), "json")) + // .WithCollapsibleSection("Full event JSON", _msb => _msb.WithCodeBlock(filterEvent.ToJson(), "json")) + .Build()); + } + + await Parallel.ForEachAsync(sync.Rooms?.Join ?? [], async (syncRoom, ct) => { + var (roomId, roomData) = syncRoom; + if (roomId == LogRoom!.RoomId || roomId == ControlRoom!.RoomId) return; + var room = hs.GetRoom(roomId); + + if (roomData.AccountData?.Events?.FirstOrDefault(x => x.Type == FilterConfiguration.EventId) is { } roomFilterEvent) { + RoomConfigurationOverrides[roomId] = roomFilterEvent.TypedContent as FilterConfiguration; + var roomName = await room.GetNameOrFallbackAsync(); + msgQueue.EnqueueMessageAsync(LogRoom, new MessageBuilder("m.notice") + .WithColoredBody("#00FF88", msb => msb.WithBody($"Filter configuration updated for ").WithMention(roomId, roomName).WithBody(" from sync.")).WithNewline() + .WithCollapsibleSection("JSON data:", msb => msb.WithCodeBlock(RoomConfigurationOverrides[roomId].ToJson(), "json")) + .Build()); + } + }); + } + + public async Task OnStartup(MatrixContentFilterConfiguration configuration) { + BotEnvironmentConfiguration _environmentConfiguration; + try { + _environmentConfiguration = await hs.GetAccountDataAsync<BotEnvironmentConfiguration>(BotEnvironmentConfiguration.EventId); + } + catch (MatrixException e) { + if (e is not { ErrorCode: MatrixException.ErrorCodes.M_NOT_FOUND }) throw; + logger.LogWarning("No environment configuration found, creating one"); + _environmentConfiguration = new BotEnvironmentConfiguration(); + } + + if (string.IsNullOrWhiteSpace(_environmentConfiguration.ControlRoomId)) { + LogRoom = await hs.CreateRoom(new() { + Name = "MatrixContentFilter logs", + Invite = configuration.Admins, + Visibility = "private" + }); + var powerlevels = await LogRoom.GetPowerLevelsAsync(); + powerlevels.EventsDefault = 20; + foreach (var admin in configuration.Admins) { + powerlevels.Users[admin] = 100; + } + + await LogRoom.SendStateEventAsync(RoomPowerLevelEventContent.EventId, powerlevels); + + _environmentConfiguration.LogRoomId = LogRoom.RoomId; + await hs.SetAccountDataAsync(BotEnvironmentConfiguration.EventId, _environmentConfiguration); + } + else { + LogRoom = hs.GetRoom(_environmentConfiguration.LogRoomId!); + } + + if (string.IsNullOrWhiteSpace(_environmentConfiguration.ControlRoomId)) { + ControlRoom = await hs.CreateRoom(new() { + Name = "MatrixContentFilter control room", + Invite = configuration.Admins, + Visibility = "private" + }); + var powerlevels = await ControlRoom.GetPowerLevelsAsync(); + powerlevels.EventsDefault = 20; + foreach (var admin in configuration.Admins) { + powerlevels.Users[admin] = 100; + } + + await ControlRoom.SendStateEventAsync(RoomPowerLevelEventContent.EventId, powerlevels); + + _environmentConfiguration.ControlRoomId = ControlRoom.RoomId; + await hs.SetAccountDataAsync(BotEnvironmentConfiguration.EventId, _environmentConfiguration); + } + else { + ControlRoom = hs.GetRoom(_environmentConfiguration.ControlRoomId!); + } + + FilterConfiguration _filterConfiguration; + try { + _filterConfiguration = await hs.GetAccountDataAsync<FilterConfiguration>(FilterConfiguration.EventId); + } + catch (MatrixException e) { + if (e is not { ErrorCode: MatrixException.ErrorCodes.M_NOT_FOUND }) throw; + logger.LogWarning("No filter configuration found, creating one"); + msgQueue.EnqueueMessageAsync(LogRoom, new MessageBuilder("m.notice").WithColoredBody("#FF0000", "No filter configuration found, creating one").Build()); + _filterConfiguration = new FilterConfiguration(); + } + + Dictionary<string, object> changes = new(); + + T Log<T>(string key, T value) { + changes[key] = value; + return value; + } + + _filterConfiguration.IgnoredUsers ??= Log("ignored_users", (List<string>) [ + hs.WhoAmI.UserId, + .. configuration.Admins + ]); + + _filterConfiguration.FileFilter ??= new(); + _filterConfiguration.FileFilter.IgnoredUsers ??= Log("file_filter->ignored_users", (List<string>) []); + _filterConfiguration.FileFilter.Allowed ??= Log("file_filter->allowed", false); + + _filterConfiguration.ImageFilter ??= new(); + _filterConfiguration.ImageFilter.IgnoredUsers ??= Log("image_filter->ignored_users", (List<string>) []); + _filterConfiguration.ImageFilter.Allowed ??= Log("image_filter->allowed", false); + + _filterConfiguration.VideoFilter ??= new(); + _filterConfiguration.VideoFilter.IgnoredUsers ??= Log("video_filter->ignored_users", (List<string>) []); + _filterConfiguration.VideoFilter.Allowed ??= Log("video_filter->allowed", false); + + _filterConfiguration.AudioFilter ??= new(); + _filterConfiguration.AudioFilter.IgnoredUsers ??= Log("audio_filter->ignored_users", (List<string>) []); + _filterConfiguration.AudioFilter.Allowed ??= Log("audio_filter->allowed", false); + + _filterConfiguration.UrlFilter ??= new(); + _filterConfiguration.UrlFilter.IgnoredUsers ??= Log("url_filter->ignored_users", (List<string>) []); + _filterConfiguration.UrlFilter.Allowed ??= Log("url_filter->allowed", false); + + if (changes.Count > 0) { + await hs.SetAccountDataAsync(FilterConfiguration.EventId, _filterConfiguration); + msgQueue.EnqueueMessageAsync(LogRoom, new MessageBuilder("m.notice").WithColoredBody("#FF0000", "Default filter configuration updated").WithNewline() + .WithTable(msb => { + msb = msb.WithTitle("Default configuration changes", 2); + + foreach (var (key, value) in changes) { + var formattedValue = value switch { + List<string> list => string.Join(", ", list), + _ => value.ToString() + }; + msb = msb.WithRow(rb => { rb.WithCell(key).WithCell(formattedValue ?? "formattedValue was null!"); }); + } + }).Build()); + } + } + + private async Task RebuildRoomConfigurations(FilterConfiguration? defaultConfig, Dictionary<string, FilterConfiguration?>? roomConfigurations) { + defaultConfig ??= await hs.GetAccountDataAsync<FilterConfiguration>(FilterConfiguration.EventId); + } + + public async Task<FilterConfiguration> GetFinalRoomConfiguration(string roomId) { + if (FinalRoomConfigurations.TryGetValue(roomId, out var config)) return config; + var roomConfig = RoomConfigurationOverrides.GetValueOrDefault(roomId); + var defaultConfig = DefaultConfiguration; + + FinalRoomConfigurations[roomId] = config; + return config; + } +} \ No newline at end of file diff --git a/MatrixContentFilter/Services/InfoCacheService.cs b/MatrixContentFilter/Services/InfoCacheService.cs new file mode 100644 index 0000000..974e873 --- /dev/null +++ b/MatrixContentFilter/Services/InfoCacheService.cs @@ -0,0 +1,29 @@ +using ArcaneLibs.Collections; +using LibMatrix.EventTypes.Spec.State; +using LibMatrix.Homeservers; + +namespace MatrixContentFilter.Services; + +public class InfoCacheService(AuthenticatedHomeserverGeneric hs) { + private static readonly ExpiringSemaphoreCache<string> DisplayNameCache = new(); + public static readonly ExpiringSemaphoreCache<string> RoomNameCache = new(); + + public async Task<string> GetDisplayNameAsync(string roomId, string userId) => + await DisplayNameCache.GetOrAdd($"{roomId}\t{userId}", async () => { + var room = hs.GetRoom(roomId); + var userState = await room.GetStateAsync<RoomMemberEventContent>(RoomMemberEventContent.EventId, userId); + if (!string.IsNullOrWhiteSpace(userState?.DisplayName)) return userState.DisplayName; + + var user = await hs.GetProfileAsync(userId); + if (!string.IsNullOrWhiteSpace(user?.DisplayName)) return user.DisplayName; + + return userId; + }, TimeSpan.FromMinutes(5)); + + public async Task<string> GetRoomNameAsync(string roomId) => + await RoomNameCache.GetOrAdd(roomId, async () => { + var room = hs.GetRoom(roomId); + var name = await room.GetNameOrFallbackAsync(); + return name; + }, TimeSpan.FromMinutes(30)); +} \ No newline at end of file diff --git a/MatrixContentFilter/Services/MatrixContentFilterBot.cs b/MatrixContentFilter/Services/MatrixContentFilterBot.cs new file mode 100644 index 0000000..321cdd4 --- /dev/null +++ b/MatrixContentFilter/Services/MatrixContentFilterBot.cs @@ -0,0 +1,143 @@ +using System.Diagnostics; +using ArcaneLibs; +using ArcaneLibs.Extensions; +using LibMatrix.Filters; +using LibMatrix.Helpers; +using LibMatrix.Homeservers; +using LibMatrix.Responses; +using MatrixContentFilter.Abstractions; +using MatrixContentFilter.Handlers.Filters; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace MatrixContentFilter.Services; + +public class MatrixContentFilterBot( + ILogger<MatrixContentFilterBot> logger, + AuthenticatedHomeserverGeneric hs, + MatrixContentFilterConfiguration configuration, + ConfigurationService filterConfigService, + IEnumerable<IContentFilter> filters, + AsyncMessageQueue msgQueue +) : BackgroundService { + /// <summary>Triggered when the application host is ready to start the service.</summary> + /// <param name="cancellationToken">Indicates that the start process has been aborted.</param> + protected override async Task ExecuteAsync(CancellationToken cancellationToken) { + try { + await filterConfigService.OnStartup(configuration); + msgQueue.EnqueueMessageAsync(filterConfigService.LogRoom, new MessageBuilder("m.notice").WithColoredBody("#00FF00", "Bot startup successful! Listening for events.") + .Build()); + msgQueue.EnqueueMessageAsync(filterConfigService.LogRoom, new MessageBuilder("m.notice").WithColoredBody("#00FF00", msb => { + msb = msb.WithBody("Inserted filters implementations (internal):").WithNewline(); + foreach (var filter in filters) { + msb = msb.WithBody(filter.GetType().FullName).WithNewline(); + } + }).Build()); + } + catch (Exception e) { + logger.LogError(e, "Error on startup"); + Environment.Exit(1); // We don't want to do a graceful shutdown if we can't start up + } + + logger.LogInformation("Bot started!"); + await Run(cancellationToken); + } + + private SyncHelper syncHelper; + + private async Task Run(CancellationToken cancellationToken) { + var syncFilter = new SyncFilter() { + Room = new() { + NotRooms = [filterConfigService.LogRoom.RoomId], + Timeline = new(notTypes: ["m.room.redaction"]) + } + }; + syncHelper = new SyncHelper(hs, logger) { + Filter = syncFilter + }; + int i = 0; + await foreach (var sync in syncHelper.EnumerateSyncAsync(cancellationToken).WithCancellation(cancellationToken)) { + // if (i++ >= 100) { + // var sw = Stopwatch.StartNew(); + // for (int gen = 0; gen < GC.MaxGeneration; gen++) { + // GC.Collect(gen, GCCollectionMode.Forced, true, true); + // } + // i = 0; + // msgQueue.EnqueueMessageAsync(filterConfigService.LogRoom, new MessageBuilder("m.notice") + // .WithBody($"Garbage collection took {sw.ElapsedMilliseconds}ms") + // .Build()); + // GC. + // } + + // GC.TryStartNoGCRegion(1024 * 1024 * 1024); + var sw = Stopwatch.StartNew(); + int actionCount = filters.Sum(x => x.ActionCount); + try { + await OnSyncReceived(sync); + } + catch (Exception e) { + logger.LogError(e, "Error processing sync"); + msgQueue.EnqueueMessageAsync(filterConfigService.LogRoom, new MessageBuilder("m.notice").WithBody($"Error processing sync: {e.Message}").Build()); + } + finally { + Console.WriteLine("Processed sync in {0}, executed {1} actions, {2} of memory usage", sw.Elapsed, filters.Sum(x => x.ActionCount) - actionCount, Util.BytesToString(Environment.WorkingSet)); + // GC.EndNoGCRegion(); + } + + // update sync filter + if (syncFilter.Room.NotRooms[0] != filterConfigService.LogRoom.RoomId) { + syncFilter.Room.NotRooms = [filterConfigService.LogRoom.RoomId]; + syncHelper.Filter = syncFilter; + } + } + } + + private int _syncCount; + + private async Task OnSyncReceived(SyncResponse sync) { + if (_syncCount++ == 0) return; // Skip initial sync :/ + + if (sync.Rooms?.Join?.ContainsKey(filterConfigService.LogRoom.RoomId) == true) { + sync.Rooms?.Join?.Remove(filterConfigService.LogRoom.RoomId); + } + + if (sync.Rooms?.Join?.ContainsKey(filterConfigService.ControlRoom.RoomId) == true) { + sync.Rooms?.Join?.Remove(filterConfigService.ControlRoom.RoomId); + } + + // HACK: Server likes to send partial timelines during elevated activity, so we need to fetch them in order not to miss events + var timelineFilter = new SyncFilter.RoomFilter.StateFilter(notTypes: ["m.room.redaction"], limit: 5000); + var limitedTimelineRooms = sync.Rooms?.Join? + .Where(x => x.Value.Timeline?.Limited ?? false) + .Select(async x => { + var (roomId, roomData) = x; + var room = hs.GetRoom(roomId); + if (roomData.Timeline?.Limited == true) { + msgQueue.EnqueueMessageAsync(filterConfigService.LogRoom, new MessageBuilder("m.notice") + .WithColoredBody("FF0000", $"Room {roomId} has limited timeline, fetching! The room may be getting spammed?") + .Build()); + roomData.Timeline.Events ??= []; + var newEvents = await room.GetMessagesAsync(roomData.Timeline.PrevBatch ?? "", 500, filter: timelineFilter.ToJson(ignoreNull: true, indent: false)); + roomData.Timeline.Events.MergeBy(newEvents.Chunk, (x, y) => x.EventId == y.EventId, (x, y) => { }); + } + }) + .ToList(); + + if (limitedTimelineRooms?.Count > 0) + await Task.WhenAll(limitedTimelineRooms); + + var tasks = Parallel.ForEachAsync(filters, async (filter, ct) => { + try { + Console.WriteLine("Processing filter {0}", filter.GetType().FullName); + await filter.ProcessSyncAsync(sync); + } + catch (Exception e) { + logger.LogError(e, "Error processing sync with filter {filter}", filter.GetType().FullName); + msgQueue.EnqueueMessageAsync(filterConfigService.LogRoom, new MessageBuilder("m.notice") + .WithBody($"Error processing sync with filter {filter.GetType().FullName}: {e.Message}").Build()); + } + }); + + await tasks; + } +} \ No newline at end of file diff --git a/MatrixContentFilter/appsettings.Development.json b/MatrixContentFilter/appsettings.Development.json new file mode 100644 index 0000000..29f9c88 --- /dev/null +++ b/MatrixContentFilter/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": "?" + }, + "MatrixContentFilter": { + // List of people who should be invited to the control room + "Admins": [ + "@emma:conduit.rory.gay", + "@emma:rory.gay" + ] + } +} diff --git a/MatrixContentFilter/appsettings.json b/MatrixContentFilter/appsettings.json new file mode 100644 index 0000000..6ba02f3 --- /dev/null +++ b/MatrixContentFilter/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} diff --git a/MatrixContentFilter/deps.nix b/MatrixContentFilter/deps.nix new file mode 100644 index 0000000..bff58f4 --- /dev/null +++ b/MatrixContentFilter/deps.nix @@ -0,0 +1,35 @@ +{ fetchNuGet }: [ + (fetchNuGet { pname = "Microsoft.Extensions.Configuration"; version = "8.0.0"; sha256 = "080kab87qgq2kh0ijry5kfdiq9afyzb8s0k3jqi5zbbi540yq4zl"; }) + (fetchNuGet { pname = "Microsoft.Extensions.Configuration.Abstractions"; version = "8.0.0"; sha256 = "1jlpa4ggl1gr5fs7fdcw04li3y3iy05w3klr9lrrlc7v8w76kq71"; }) + (fetchNuGet { pname = "Microsoft.Extensions.Configuration.Binder"; version = "8.0.0"; sha256 = "1m0gawiz8f5hc3li9vd5psddlygwgkiw13d7div87kmkf4idza8r"; }) + (fetchNuGet { pname = "Microsoft.Extensions.Configuration.CommandLine"; version = "8.0.0"; sha256 = "026f7f2iv6ph2dc5rnslll0bly8qcx5clmh2nn9hgyqjizzc4qvy"; }) + (fetchNuGet { pname = "Microsoft.Extensions.Configuration.EnvironmentVariables"; version = "8.0.0"; sha256 = "13qb8wz3k59ihq0mjcqz1kwrpyzxn5da4dhk2pvcgc42z9kcbf7r"; }) + (fetchNuGet { pname = "Microsoft.Extensions.Configuration.FileExtensions"; version = "8.0.0"; sha256 = "1jrmlfzy4h32nzf1nm5q8bhkpx958b0ww9qx1k1zm4pyaf6mqb04"; }) + (fetchNuGet { pname = "Microsoft.Extensions.Configuration.Json"; version = "8.0.0"; sha256 = "1n3ss26v1lq6b69fxk1vz3kqv9ppxq8ypgdqpd7415xrq66y4bqn"; }) + (fetchNuGet { pname = "Microsoft.Extensions.Configuration.UserSecrets"; version = "8.0.0"; sha256 = "1br01zhzhnxjzqx63bxd25x48y9xs69hcs71pjni8y9kl50zja7z"; }) + (fetchNuGet { pname = "Microsoft.Extensions.DependencyInjection"; version = "8.0.0"; sha256 = "0i7qziz0iqmbk8zzln7kx9vd0lbx1x3va0yi3j1bgkjir13h78ps"; }) + (fetchNuGet { pname = "Microsoft.Extensions.DependencyInjection.Abstractions"; version = "8.0.0"; sha256 = "1zw0bpp5742jzx03wvqc8csnvsbgdqi0ls9jfc5i2vd3cl8b74pg"; }) + (fetchNuGet { pname = "Microsoft.Extensions.DependencyInjection.Abstractions"; version = "8.0.1"; sha256 = "1wyhpamm1nqjfi3r463dhxljdlr6rm2ax4fvbgq2s0j3jhpdhd4p"; }) + (fetchNuGet { pname = "Microsoft.Extensions.Diagnostics"; version = "8.0.0"; sha256 = "0ghwkld91k20hcbmzg2137w81mzzdh8hfaapdwckhza0vipya4kw"; }) + (fetchNuGet { pname = "Microsoft.Extensions.Diagnostics.Abstractions"; version = "8.0.0"; sha256 = "15m4j6w9n8h0mj7hlfzb83hd3wn7aq1s7fxbicm16slsjfwzj82i"; }) + (fetchNuGet { pname = "Microsoft.Extensions.FileProviders.Abstractions"; version = "8.0.0"; sha256 = "1idq65fxwcn882c06yci7nscy9i0rgw6mqjrl7362prvvsd9f15r"; }) + (fetchNuGet { pname = "Microsoft.Extensions.FileProviders.Physical"; version = "8.0.0"; sha256 = "05wxjvjbx79ir7vfkri6b28k8zl8fa6bbr0i7gahqrim2ijvkp6v"; }) + (fetchNuGet { pname = "Microsoft.Extensions.FileSystemGlobbing"; version = "8.0.0"; sha256 = "1igf2bqism22fxv7km5yv028r4rg12a4lki2jh4xg3brjkagiv7q"; }) + (fetchNuGet { pname = "Microsoft.Extensions.Hosting"; version = "8.0.0"; sha256 = "1f2af5m1yny8b43251gsj75hjd9ixni1clcldy8cg91z1vxxm8dh"; }) + (fetchNuGet { pname = "Microsoft.Extensions.Hosting.Abstractions"; version = "8.0.0"; sha256 = "00d5dwmzw76iy8z40ly01hy9gly49a7rpf7k7m99vrid1kxp346h"; }) + (fetchNuGet { pname = "Microsoft.Extensions.Logging"; version = "8.0.0"; sha256 = "0nppj34nmq25gnrg0wh1q22y4wdqbih4ax493f226azv8mkp9s1i"; }) + (fetchNuGet { pname = "Microsoft.Extensions.Logging.Abstractions"; version = "8.0.0"; sha256 = "1klcqhg3hk55hb6vmjiq2wgqidsl81aldw0li2z98lrwx26msrr6"; }) + (fetchNuGet { pname = "Microsoft.Extensions.Logging.Abstractions"; version = "8.0.1"; sha256 = "0i9pgmk60b8xlws3q9z890gim1xjq42dhyh6dj4xvbycmgg1x1sd"; }) + (fetchNuGet { pname = "Microsoft.Extensions.Logging.Configuration"; version = "8.0.0"; sha256 = "1d9b734vnll935661wqkgl7ry60rlh5p876l2bsa930mvfsaqfcv"; }) + (fetchNuGet { pname = "Microsoft.Extensions.Logging.Console"; version = "8.0.0"; sha256 = "1mvp3ipw7k33v2qw2yrvc4vl5yzgpk3yxa94gg0gz7wmcmhzvmkd"; }) + (fetchNuGet { pname = "Microsoft.Extensions.Logging.Debug"; version = "8.0.0"; sha256 = "1h7mg97lj0ss47kq7zwnihh9c6xcrkwrr8ffhc16qcsrh36sg6q0"; }) + (fetchNuGet { pname = "Microsoft.Extensions.Logging.EventLog"; version = "8.0.0"; sha256 = "05vfrxw7mlwlwhsl6r4yrhxk3sd8dv5sl0hdlcpgw62n53incw5x"; }) + (fetchNuGet { pname = "Microsoft.Extensions.Logging.EventSource"; version = "8.0.0"; sha256 = "0gbjll6p03rmw0cf8fp0p8cxzn9awmzv8hvnyqbczrkax5h7p94i"; }) + (fetchNuGet { pname = "Microsoft.Extensions.Options"; version = "8.0.0"; sha256 = "0p50qn6zhinzyhq9sy5svnmqqwhw2jajs2pbjh9sah504wjvhscz"; }) + (fetchNuGet { pname = "Microsoft.Extensions.Options.ConfigurationExtensions"; version = "8.0.0"; sha256 = "04nm8v5a3zp0ill7hjnwnja3s2676b4wffdri8hdk2341p7mp403"; }) + (fetchNuGet { pname = "Microsoft.Extensions.Primitives"; version = "8.0.0"; sha256 = "0aldaz5aapngchgdr7dax9jw5wy7k7hmjgjpfgfv1wfif27jlkqm"; }) + (fetchNuGet { pname = "System.Diagnostics.DiagnosticSource"; version = "8.0.0"; sha256 = "0nzra1i0mljvmnj1qqqg37xs7bl71fnpl68nwmdajchh65l878zr"; }) + (fetchNuGet { pname = "System.Diagnostics.EventLog"; version = "8.0.0"; sha256 = "1xnvcidh2qf6k7w8ij1rvj0viqkq84cq47biw0c98xhxg5rk3pxf"; }) + (fetchNuGet { pname = "System.Text.Encodings.Web"; version = "8.0.0"; sha256 = "1wbypkx0m8dgpsaqgyywz4z760xblnwalb241d5qv9kx8m128i11"; }) + (fetchNuGet { pname = "System.Text.Json"; version = "8.0.0"; sha256 = "134savxw0sq7s448jnzw17bxcijsi1v38mirpbb6zfxmqlf04msw"; }) +] |