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 +- .../Common/MjolnirShortcodeEventContent.cs | 11 + .../EventTypes/Common/MjolnirShortcodeEventData.cs | 11 - .../EventTypes/Common/RoomEmotesEventContent.cs | 26 ++ LibMatrix/EventTypes/Common/RoomEmotesEventData.cs | 26 -- LibMatrix/EventTypes/MatrixEventAttribute.cs | 7 + .../Spec/Ephemeral/PresenceStateEventContent.cs | 21 ++ .../Spec/Ephemeral/RoomTypingEventContent.cs | 11 + .../EventTypes/Spec/RoomMessageEventContent.cs | 33 ++ LibMatrix/EventTypes/Spec/RoomMessageEventData.cs | 33 -- .../Spec/State/CanonicalAliasEventContent.cs | 13 - .../EventTypes/Spec/State/GuestAccessEventData.cs | 16 - .../Spec/State/HistoryVisibilityEventData.cs | 11 - .../EventTypes/Spec/State/JoinRulesEventData.cs | 30 -- .../State/Policy/PolicyRuleStateEventContent.cs | 56 ++++ .../Spec/State/PolicyRuleStateEventData.cs | 56 ---- .../Spec/State/PresenceStateEventData.cs | 21 -- .../Spec/State/ProfileResponseEventContent.cs | 12 + .../Spec/State/ProfileResponseEventData.cs | 12 - .../EventTypes/Spec/State/RoomAliasEventData.cs | 11 - .../EventTypes/Spec/State/RoomAvatarEventData.cs | 28 -- .../EventTypes/Spec/State/RoomCreateEventData.cs | 31 -- .../Spec/State/RoomEncryptionEventData.cs | 15 - .../Spec/State/RoomInfo/RoomAliasEventContent.cs | 11 + .../Spec/State/RoomInfo/RoomAvatarEventContent.cs | 28 ++ .../RoomInfo/RoomCanonicalAliasEventContent.cs | 13 + .../Spec/State/RoomInfo/RoomCreateEventContent.cs | 31 ++ .../State/RoomInfo/RoomEncryptionEventContent.cs | 15 + .../State/RoomInfo/RoomGuestAccessEventContent.cs | 16 + .../RoomInfo/RoomHistoryVisibilityEventContent.cs | 11 + .../State/RoomInfo/RoomJoinRulesEventContent.cs | 30 ++ .../Spec/State/RoomInfo/RoomMemberEventContent.cs | 29 ++ .../Spec/State/RoomInfo/RoomNameEventContent.cs | 11 + .../Spec/State/RoomInfo/RoomPinnedEventContent.cs | 11 + .../State/RoomInfo/RoomPowerLevelEventContent.cs | 56 ++++ .../State/RoomInfo/RoomServerACLEventContent.cs | 17 ++ .../Spec/State/RoomInfo/RoomTopicEventContent.cs | 12 + .../EventTypes/Spec/State/RoomMemberEventData.cs | 29 -- .../EventTypes/Spec/State/RoomNameEventData.cs | 11 - .../EventTypes/Spec/State/RoomPinnedEventData.cs | 11 - .../Spec/State/RoomPowerLevelEventData.cs | 56 ---- .../EventTypes/Spec/State/RoomTopicEventData.cs | 12 - .../EventTypes/Spec/State/RoomTypingEventData.cs | 11 - .../EventTypes/Spec/State/ServerACLEventData.cs | 17 -- .../Spec/State/Space/SpaceChildEventContent.cs | 15 + .../Spec/State/Space/SpaceParentEventContent.cs | 14 + .../EventTypes/Spec/State/SpaceChildEventData.cs | 15 - .../EventTypes/Spec/State/SpaceParentEventData.cs | 14 - LibMatrix/EventTypes/UnknownStateEventContent.cs | 7 + LibMatrix/EventTypes/UnknownStateEventData.cs | 7 - LibMatrix/Extensions/EnumerableExtensions.cs | 28 ++ LibMatrix/Extensions/HttpClientExtensions.cs | 14 +- LibMatrix/Helpers/MatrixEventAttribute.cs | 7 - LibMatrix/Helpers/MessageFormatter.cs | 9 +- LibMatrix/Helpers/SyncHelper.cs | 259 +++++----------- LibMatrix/Helpers/SyncStateResolver.cs | 174 +++++++++++ .../Homeservers/AuthenticatedHomeserverGeneric.cs | 42 ++- LibMatrix/Homeservers/RemoteHomeServer.cs | 16 +- LibMatrix/Interfaces/EventContent.cs | 26 ++ LibMatrix/Interfaces/IStateEventType.cs | 23 -- LibMatrix/Responses/CreateRoomRequest.cs | 1 + LibMatrix/Responses/LoginResponse.cs | 2 +- LibMatrix/Responses/StateEventResponse.cs | 52 ---- LibMatrix/Responses/SyncResponse.cs | 118 +++++++ LibMatrix/RoomTypes/GenericRoom.cs | 35 ++- LibMatrix/Services/HomeserverProviderService.cs | 11 +- LibMatrix/Services/HomeserverResolverService.cs | 4 + LibMatrix/StateEvent.cs | 62 +++- .../Abstractions/HomeserverAbstraction.cs | 42 +-- .../Abstractions/RoomAbstraction.cs | 34 ++- Tests/LibMatrix.Tests/Tests/RoomEventTests.cs | 160 ++++++++++ Tests/LibMatrix.Tests/Tests/RoomTests.cs | 145 +-------- Tests/LibMatrix.Tests/Tests/TestCleanup.cs | 76 +++++ .../Services/CommandListenerHostedService.cs | 5 +- 92 files changed, 2020 insertions(+), 1507 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 create mode 100644 LibMatrix/EventTypes/Common/MjolnirShortcodeEventContent.cs delete mode 100644 LibMatrix/EventTypes/Common/MjolnirShortcodeEventData.cs create mode 100644 LibMatrix/EventTypes/Common/RoomEmotesEventContent.cs delete mode 100644 LibMatrix/EventTypes/Common/RoomEmotesEventData.cs create mode 100644 LibMatrix/EventTypes/MatrixEventAttribute.cs create mode 100644 LibMatrix/EventTypes/Spec/Ephemeral/PresenceStateEventContent.cs create mode 100644 LibMatrix/EventTypes/Spec/Ephemeral/RoomTypingEventContent.cs create mode 100644 LibMatrix/EventTypes/Spec/RoomMessageEventContent.cs delete mode 100644 LibMatrix/EventTypes/Spec/RoomMessageEventData.cs delete mode 100644 LibMatrix/EventTypes/Spec/State/CanonicalAliasEventContent.cs delete mode 100644 LibMatrix/EventTypes/Spec/State/GuestAccessEventData.cs delete mode 100644 LibMatrix/EventTypes/Spec/State/HistoryVisibilityEventData.cs delete mode 100644 LibMatrix/EventTypes/Spec/State/JoinRulesEventData.cs create mode 100644 LibMatrix/EventTypes/Spec/State/Policy/PolicyRuleStateEventContent.cs delete mode 100644 LibMatrix/EventTypes/Spec/State/PolicyRuleStateEventData.cs delete mode 100644 LibMatrix/EventTypes/Spec/State/PresenceStateEventData.cs create mode 100644 LibMatrix/EventTypes/Spec/State/ProfileResponseEventContent.cs delete mode 100644 LibMatrix/EventTypes/Spec/State/ProfileResponseEventData.cs delete mode 100644 LibMatrix/EventTypes/Spec/State/RoomAliasEventData.cs delete mode 100644 LibMatrix/EventTypes/Spec/State/RoomAvatarEventData.cs delete mode 100644 LibMatrix/EventTypes/Spec/State/RoomCreateEventData.cs delete mode 100644 LibMatrix/EventTypes/Spec/State/RoomEncryptionEventData.cs create mode 100644 LibMatrix/EventTypes/Spec/State/RoomInfo/RoomAliasEventContent.cs create mode 100644 LibMatrix/EventTypes/Spec/State/RoomInfo/RoomAvatarEventContent.cs create mode 100644 LibMatrix/EventTypes/Spec/State/RoomInfo/RoomCanonicalAliasEventContent.cs create mode 100644 LibMatrix/EventTypes/Spec/State/RoomInfo/RoomCreateEventContent.cs create mode 100644 LibMatrix/EventTypes/Spec/State/RoomInfo/RoomEncryptionEventContent.cs create mode 100644 LibMatrix/EventTypes/Spec/State/RoomInfo/RoomGuestAccessEventContent.cs create mode 100644 LibMatrix/EventTypes/Spec/State/RoomInfo/RoomHistoryVisibilityEventContent.cs create mode 100644 LibMatrix/EventTypes/Spec/State/RoomInfo/RoomJoinRulesEventContent.cs create mode 100644 LibMatrix/EventTypes/Spec/State/RoomInfo/RoomMemberEventContent.cs create mode 100644 LibMatrix/EventTypes/Spec/State/RoomInfo/RoomNameEventContent.cs create mode 100644 LibMatrix/EventTypes/Spec/State/RoomInfo/RoomPinnedEventContent.cs create mode 100644 LibMatrix/EventTypes/Spec/State/RoomInfo/RoomPowerLevelEventContent.cs create mode 100644 LibMatrix/EventTypes/Spec/State/RoomInfo/RoomServerACLEventContent.cs create mode 100644 LibMatrix/EventTypes/Spec/State/RoomInfo/RoomTopicEventContent.cs delete mode 100644 LibMatrix/EventTypes/Spec/State/RoomMemberEventData.cs delete mode 100644 LibMatrix/EventTypes/Spec/State/RoomNameEventData.cs delete mode 100644 LibMatrix/EventTypes/Spec/State/RoomPinnedEventData.cs delete mode 100644 LibMatrix/EventTypes/Spec/State/RoomPowerLevelEventData.cs delete mode 100644 LibMatrix/EventTypes/Spec/State/RoomTopicEventData.cs delete mode 100644 LibMatrix/EventTypes/Spec/State/RoomTypingEventData.cs delete mode 100644 LibMatrix/EventTypes/Spec/State/ServerACLEventData.cs create mode 100644 LibMatrix/EventTypes/Spec/State/Space/SpaceChildEventContent.cs create mode 100644 LibMatrix/EventTypes/Spec/State/Space/SpaceParentEventContent.cs delete mode 100644 LibMatrix/EventTypes/Spec/State/SpaceChildEventData.cs delete mode 100644 LibMatrix/EventTypes/Spec/State/SpaceParentEventData.cs create mode 100644 LibMatrix/EventTypes/UnknownStateEventContent.cs delete mode 100644 LibMatrix/EventTypes/UnknownStateEventData.cs create mode 100644 LibMatrix/Extensions/EnumerableExtensions.cs delete mode 100644 LibMatrix/Helpers/MatrixEventAttribute.cs create mode 100644 LibMatrix/Helpers/SyncStateResolver.cs create mode 100644 LibMatrix/Interfaces/EventContent.cs delete mode 100644 LibMatrix/Interfaces/IStateEventType.cs delete mode 100644 LibMatrix/Responses/StateEventResponse.cs create mode 100644 LibMatrix/Responses/SyncResponse.cs create mode 100644 Tests/LibMatrix.Tests/Tests/RoomEventTests.cs create mode 100644 Tests/LibMatrix.Tests/Tests/TestCleanup.cs 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? Emoticons { get; set; } + + [JsonPropertyName("images")] + public Dictionary? Images { get; set; } + + [JsonPropertyName("pack")] + public PackInfo? Pack { get; set; } + + public class EmoticonData { + [JsonPropertyName("url")] + public string? Url { get; set; } + } + + public class PackInfo { + + } +} diff --git a/LibMatrix/EventTypes/Common/RoomEmotesEventData.cs b/LibMatrix/EventTypes/Common/RoomEmotesEventData.cs deleted file mode 100644 index abf936c..0000000 --- a/LibMatrix/EventTypes/Common/RoomEmotesEventData.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Text.Json.Serialization; -using LibMatrix.Helpers; -using LibMatrix.Interfaces; - -namespace LibMatrix.EventTypes.Common; - -[MatrixEvent(EventName = "im.ponies.room_emotes")] -public class RoomEmotesEventContent : EventContent { - [JsonPropertyName("emoticons")] - public Dictionary? Emoticons { get; set; } - - [JsonPropertyName("images")] - public Dictionary? Images { get; set; } - - [JsonPropertyName("pack")] - public PackInfo? Pack { get; set; } - - public class EmoticonData { - [JsonPropertyName("url")] - public string? Url { get; set; } - } - - public class PackInfo { - - } -} diff --git a/LibMatrix/EventTypes/MatrixEventAttribute.cs b/LibMatrix/EventTypes/MatrixEventAttribute.cs new file mode 100644 index 0000000..92334d0 --- /dev/null +++ b/LibMatrix/EventTypes/MatrixEventAttribute.cs @@ -0,0 +1,7 @@ +namespace LibMatrix.EventTypes; + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public class MatrixEventAttribute : Attribute { + public string EventName { get; set; } + public bool Legacy { get; set; } +} diff --git a/LibMatrix/EventTypes/Spec/Ephemeral/PresenceStateEventContent.cs b/LibMatrix/EventTypes/Spec/Ephemeral/PresenceStateEventContent.cs new file mode 100644 index 0000000..b12da5b --- /dev/null +++ b/LibMatrix/EventTypes/Spec/Ephemeral/PresenceStateEventContent.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; +using LibMatrix.Helpers; +using LibMatrix.Interfaces; + +namespace LibMatrix.EventTypes.Spec.State; + +[MatrixEvent(EventName = "m.presence")] +public class PresenceEventContent : EventContent { + [JsonPropertyName("presence")] + public string Presence { get; set; } + [JsonPropertyName("last_active_ago")] + public long LastActiveAgo { get; set; } + [JsonPropertyName("currently_active")] + public bool CurrentlyActive { get; set; } + [JsonPropertyName("status_msg")] + public string StatusMessage { get; set; } + [JsonPropertyName("avatar_url")] + public string AvatarUrl { get; set; } + [JsonPropertyName("displayname")] + public string DisplayName { get; set; } +} diff --git a/LibMatrix/EventTypes/Spec/Ephemeral/RoomTypingEventContent.cs b/LibMatrix/EventTypes/Spec/Ephemeral/RoomTypingEventContent.cs new file mode 100644 index 0000000..01cfacf --- /dev/null +++ b/LibMatrix/EventTypes/Spec/Ephemeral/RoomTypingEventContent.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; +using LibMatrix.Helpers; +using LibMatrix.Interfaces; + +namespace LibMatrix.EventTypes.Spec.State; + +[MatrixEvent(EventName = "m.typing")] +public class RoomTypingEventContent : EventContent { + [JsonPropertyName("user_ids")] + public string[]? UserIds { get; set; } +} diff --git a/LibMatrix/EventTypes/Spec/RoomMessageEventContent.cs b/LibMatrix/EventTypes/Spec/RoomMessageEventContent.cs new file mode 100644 index 0000000..f8ee58b --- /dev/null +++ b/LibMatrix/EventTypes/Spec/RoomMessageEventContent.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; +using LibMatrix.Helpers; +using LibMatrix.Interfaces; + +namespace LibMatrix.EventTypes.Spec; + +[MatrixEvent(EventName = "m.room.message")] +public class RoomMessageEventContent : EventContent { + public RoomMessageEventContent(string? messageType = "m.notice", string? body = null) { + MessageType = messageType; + Body = body; + } + + [JsonPropertyName("body")] + public string Body { get; set; } + + [JsonPropertyName("msgtype")] + public string MessageType { get; set; } = "m.notice"; + + [JsonPropertyName("formatted_body")] + public string FormattedBody { get; set; } + + [JsonPropertyName("format")] + public string Format { get; set; } + + /// + /// Media URI for this message, if any + /// + [JsonPropertyName("url")] + public string? Url { get; set; } + + public string? FileName { get; set; } +} diff --git a/LibMatrix/EventTypes/Spec/RoomMessageEventData.cs b/LibMatrix/EventTypes/Spec/RoomMessageEventData.cs deleted file mode 100644 index f8ee58b..0000000 --- a/LibMatrix/EventTypes/Spec/RoomMessageEventData.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Text.Json.Serialization; -using LibMatrix.Helpers; -using LibMatrix.Interfaces; - -namespace LibMatrix.EventTypes.Spec; - -[MatrixEvent(EventName = "m.room.message")] -public class RoomMessageEventContent : EventContent { - public RoomMessageEventContent(string? messageType = "m.notice", string? body = null) { - MessageType = messageType; - Body = body; - } - - [JsonPropertyName("body")] - public string Body { get; set; } - - [JsonPropertyName("msgtype")] - public string MessageType { get; set; } = "m.notice"; - - [JsonPropertyName("formatted_body")] - public string FormattedBody { get; set; } - - [JsonPropertyName("format")] - public string Format { get; set; } - - /// - /// Media URI for this message, if any - /// - [JsonPropertyName("url")] - public string? Url { get; set; } - - public string? FileName { get; set; } -} diff --git a/LibMatrix/EventTypes/Spec/State/CanonicalAliasEventContent.cs b/LibMatrix/EventTypes/Spec/State/CanonicalAliasEventContent.cs deleted file mode 100644 index 71f3d0d..0000000 --- a/LibMatrix/EventTypes/Spec/State/CanonicalAliasEventContent.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Text.Json.Serialization; -using LibMatrix.Helpers; -using LibMatrix.Interfaces; - -namespace LibMatrix.EventTypes.Spec.State; - -[MatrixEvent(EventName = "m.room.canonical_alias")] -public class CanonicalAliasEventContent : EventContent { - [JsonPropertyName("alias")] - public string? Alias { get; set; } - [JsonPropertyName("alt_aliases")] - public string[]? AltAliases { get; set; } -} diff --git a/LibMatrix/EventTypes/Spec/State/GuestAccessEventData.cs b/LibMatrix/EventTypes/Spec/State/GuestAccessEventData.cs deleted file mode 100644 index af1b2ce..0000000 --- a/LibMatrix/EventTypes/Spec/State/GuestAccessEventData.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Text.Json.Serialization; -using LibMatrix.Helpers; -using LibMatrix.Interfaces; - -namespace LibMatrix.EventTypes.Spec.State; - -[MatrixEvent(EventName = "m.room.guest_access")] -public class GuestAccessEventContent : EventContent { - [JsonPropertyName("guest_access")] - public string GuestAccess { get; set; } - - public bool IsGuestAccessEnabled { - get => GuestAccess == "can_join"; - set => GuestAccess = value ? "can_join" : "forbidden"; - } -} diff --git a/LibMatrix/EventTypes/Spec/State/HistoryVisibilityEventData.cs b/LibMatrix/EventTypes/Spec/State/HistoryVisibilityEventData.cs deleted file mode 100644 index b57ade5..0000000 --- a/LibMatrix/EventTypes/Spec/State/HistoryVisibilityEventData.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Text.Json.Serialization; -using LibMatrix.Helpers; -using LibMatrix.Interfaces; - -namespace LibMatrix.EventTypes.Spec.State; - -[MatrixEvent(EventName = "m.room.history_visibility")] -public class HistoryVisibilityEventContent : EventContent { - [JsonPropertyName("history_visibility")] - public string HistoryVisibility { get; set; } -} diff --git a/LibMatrix/EventTypes/Spec/State/JoinRulesEventData.cs b/LibMatrix/EventTypes/Spec/State/JoinRulesEventData.cs deleted file mode 100644 index 0098bef..0000000 --- a/LibMatrix/EventTypes/Spec/State/JoinRulesEventData.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Text.Json.Serialization; -using LibMatrix.Helpers; -using LibMatrix.Interfaces; - -namespace LibMatrix.EventTypes.Spec.State; - -[MatrixEvent(EventName = "m.room.join_rules")] -public class JoinRulesEventContent : EventContent { - private static string Public = "public"; - private static string Invite = "invite"; - private static string Knock = "knock"; - - /// - /// one of ["public", "invite", "knock", "restricted", "knock_restricted"] - /// "private" is reserved without implementation! - /// - [JsonPropertyName("join_rule")] - public string JoinRule { get; set; } - - [JsonPropertyName("allow")] - public List Allow { get; set; } - - public class AllowEntry { - [JsonPropertyName("type")] - public string Type { get; set; } - - [JsonPropertyName("room_id")] - public string RoomId { get; set; } - } -} diff --git a/LibMatrix/EventTypes/Spec/State/Policy/PolicyRuleStateEventContent.cs b/LibMatrix/EventTypes/Spec/State/Policy/PolicyRuleStateEventContent.cs new file mode 100644 index 0000000..fde02c1 --- /dev/null +++ b/LibMatrix/EventTypes/Spec/State/Policy/PolicyRuleStateEventContent.cs @@ -0,0 +1,56 @@ +using System.Text.Json.Serialization; +using LibMatrix.Helpers; +using LibMatrix.Interfaces; + +namespace LibMatrix.EventTypes.Spec.State; + +[MatrixEvent(EventName = "m.policy.rule.user")] +[MatrixEvent(EventName = "m.policy.rule.server")] +[MatrixEvent(EventName = "org.matrix.mjolnir.rule.server")] +public class PolicyRuleEventContent : EventContent { + /// + /// Entity this ban applies to, can use * and ? as globs. + /// + [JsonPropertyName("entity")] + public string Entity { get; set; } + + /// + /// Reason this user is banned + /// + [JsonPropertyName("reason")] + public string? Reason { get; set; } + + /// + /// Suggested action to take + /// + [JsonPropertyName("recommendation")] + public string? Recommendation { get; set; } + + /// + /// 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 = ((DateTimeOffset)value).ToUnixTimeMilliseconds(); + } +} + +public static class PolicyRecommendationTypes { + /// + /// Ban this user + /// + public static string Ban = "m.ban"; + + /// + /// Mute this user + /// + public static string Mute = "support.feline.policy.recommendation_mute"; //stable prefix: m.mute, msc pending +} diff --git a/LibMatrix/EventTypes/Spec/State/PolicyRuleStateEventData.cs b/LibMatrix/EventTypes/Spec/State/PolicyRuleStateEventData.cs deleted file mode 100644 index fde02c1..0000000 --- a/LibMatrix/EventTypes/Spec/State/PolicyRuleStateEventData.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.Text.Json.Serialization; -using LibMatrix.Helpers; -using LibMatrix.Interfaces; - -namespace LibMatrix.EventTypes.Spec.State; - -[MatrixEvent(EventName = "m.policy.rule.user")] -[MatrixEvent(EventName = "m.policy.rule.server")] -[MatrixEvent(EventName = "org.matrix.mjolnir.rule.server")] -public class PolicyRuleEventContent : EventContent { - /// - /// Entity this ban applies to, can use * and ? as globs. - /// - [JsonPropertyName("entity")] - public string Entity { get; set; } - - /// - /// Reason this user is banned - /// - [JsonPropertyName("reason")] - public string? Reason { get; set; } - - /// - /// Suggested action to take - /// - [JsonPropertyName("recommendation")] - public string? Recommendation { get; set; } - - /// - /// 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 = ((DateTimeOffset)value).ToUnixTimeMilliseconds(); - } -} - -public static class PolicyRecommendationTypes { - /// - /// Ban this user - /// - public static string Ban = "m.ban"; - - /// - /// Mute this user - /// - public static string Mute = "support.feline.policy.recommendation_mute"; //stable prefix: m.mute, msc pending -} diff --git a/LibMatrix/EventTypes/Spec/State/PresenceStateEventData.cs b/LibMatrix/EventTypes/Spec/State/PresenceStateEventData.cs deleted file mode 100644 index b12da5b..0000000 --- a/LibMatrix/EventTypes/Spec/State/PresenceStateEventData.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Text.Json.Serialization; -using LibMatrix.Helpers; -using LibMatrix.Interfaces; - -namespace LibMatrix.EventTypes.Spec.State; - -[MatrixEvent(EventName = "m.presence")] -public class PresenceEventContent : EventContent { - [JsonPropertyName("presence")] - public string Presence { get; set; } - [JsonPropertyName("last_active_ago")] - public long LastActiveAgo { get; set; } - [JsonPropertyName("currently_active")] - public bool CurrentlyActive { get; set; } - [JsonPropertyName("status_msg")] - public string StatusMessage { get; set; } - [JsonPropertyName("avatar_url")] - public string AvatarUrl { get; set; } - [JsonPropertyName("displayname")] - public string DisplayName { get; set; } -} diff --git a/LibMatrix/EventTypes/Spec/State/ProfileResponseEventContent.cs b/LibMatrix/EventTypes/Spec/State/ProfileResponseEventContent.cs new file mode 100644 index 0000000..893fce1 --- /dev/null +++ b/LibMatrix/EventTypes/Spec/State/ProfileResponseEventContent.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; +using LibMatrix.Interfaces; + +namespace LibMatrix.EventTypes.Spec.State; + +public class ProfileResponseEventContent : EventContent { + [JsonPropertyName("avatar_url")] + public string? AvatarUrl { get; set; } + + [JsonPropertyName("displayname")] + public string? DisplayName { get; set; } +} diff --git a/LibMatrix/EventTypes/Spec/State/ProfileResponseEventData.cs b/LibMatrix/EventTypes/Spec/State/ProfileResponseEventData.cs deleted file mode 100644 index 893fce1..0000000 --- a/LibMatrix/EventTypes/Spec/State/ProfileResponseEventData.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Text.Json.Serialization; -using LibMatrix.Interfaces; - -namespace LibMatrix.EventTypes.Spec.State; - -public class ProfileResponseEventContent : EventContent { - [JsonPropertyName("avatar_url")] - public string? AvatarUrl { get; set; } - - [JsonPropertyName("displayname")] - public string? DisplayName { get; set; } -} diff --git a/LibMatrix/EventTypes/Spec/State/RoomAliasEventData.cs b/LibMatrix/EventTypes/Spec/State/RoomAliasEventData.cs deleted file mode 100644 index 5b0e914..0000000 --- a/LibMatrix/EventTypes/Spec/State/RoomAliasEventData.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Text.Json.Serialization; -using LibMatrix.Helpers; -using LibMatrix.Interfaces; - -namespace LibMatrix.EventTypes.Spec.State; - -[MatrixEvent(EventName = "m.room.alias")] -public class RoomAliasEventContent : EventContent { - [JsonPropertyName("aliases")] - public List? Aliases { get; set; } -} diff --git a/LibMatrix/EventTypes/Spec/State/RoomAvatarEventData.cs b/LibMatrix/EventTypes/Spec/State/RoomAvatarEventData.cs deleted file mode 100644 index 601d014..0000000 --- a/LibMatrix/EventTypes/Spec/State/RoomAvatarEventData.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Text.Json.Serialization; -using LibMatrix.Helpers; -using LibMatrix.Interfaces; - -namespace LibMatrix.EventTypes.Spec.State; - -[MatrixEvent(EventName = "m.room.avatar")] -public class RoomAvatarEventContent : EventContent { - [JsonPropertyName("url")] - public string? Url { get; set; } - - [JsonPropertyName("info")] - public RoomAvatarInfo? Info { get; set; } - - public class RoomAvatarInfo { - [JsonPropertyName("h")] - public int? Height { get; set; } - - [JsonPropertyName("w")] - public int? Width { get; set; } - - [JsonPropertyName("mimetype")] - public string? MimeType { get; set; } - - [JsonPropertyName("size")] - public int? Size { get; set; } - } -} diff --git a/LibMatrix/EventTypes/Spec/State/RoomCreateEventData.cs b/LibMatrix/EventTypes/Spec/State/RoomCreateEventData.cs deleted file mode 100644 index c5bf14e..0000000 --- a/LibMatrix/EventTypes/Spec/State/RoomCreateEventData.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Text.Json.Serialization; -using LibMatrix.Helpers; -using LibMatrix.Interfaces; - -namespace LibMatrix.EventTypes.Spec.State; - -[MatrixEvent(EventName = "m.room.create")] -public class RoomCreateEventContent : EventContent { - [JsonPropertyName("room_version")] - public string? RoomVersion { get; set; } - - [JsonPropertyName("creator")] - public string? Creator { get; set; } - - [JsonPropertyName("m.federate")] - public bool? Federate { get; set; } - - [JsonPropertyName("predecessor")] - public RoomCreatePredecessor? Predecessor { get; set; } - - [JsonPropertyName("type")] - public string? Type { get; set; } - - public class RoomCreatePredecessor { - [JsonPropertyName("room_id")] - public string? RoomId { get; set; } - - [JsonPropertyName("event_id")] - public string? EventId { get; set; } - } -} diff --git a/LibMatrix/EventTypes/Spec/State/RoomEncryptionEventData.cs b/LibMatrix/EventTypes/Spec/State/RoomEncryptionEventData.cs deleted file mode 100644 index 6ffa4c5..0000000 --- a/LibMatrix/EventTypes/Spec/State/RoomEncryptionEventData.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Text.Json.Serialization; -using LibMatrix.Helpers; -using LibMatrix.Interfaces; - -namespace LibMatrix.EventTypes.Spec.State; - -[MatrixEvent(EventName = "m.room.encryption")] -public class RoomEncryptionEventContent : EventContent { - [JsonPropertyName("algorithm")] - public string? Algorithm { get; set; } - [JsonPropertyName("rotation_period_ms")] - public ulong? RotationPeriodMs { get; set; } - [JsonPropertyName("rotation_period_msgs")] - public ulong? RotationPeriodMsgs { get; set; } -} diff --git a/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomAliasEventContent.cs b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomAliasEventContent.cs new file mode 100644 index 0000000..5b0e914 --- /dev/null +++ b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomAliasEventContent.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; +using LibMatrix.Helpers; +using LibMatrix.Interfaces; + +namespace LibMatrix.EventTypes.Spec.State; + +[MatrixEvent(EventName = "m.room.alias")] +public class RoomAliasEventContent : EventContent { + [JsonPropertyName("aliases")] + public List? Aliases { get; set; } +} diff --git a/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomAvatarEventContent.cs b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomAvatarEventContent.cs new file mode 100644 index 0000000..601d014 --- /dev/null +++ b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomAvatarEventContent.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; +using LibMatrix.Helpers; +using LibMatrix.Interfaces; + +namespace LibMatrix.EventTypes.Spec.State; + +[MatrixEvent(EventName = "m.room.avatar")] +public class RoomAvatarEventContent : EventContent { + [JsonPropertyName("url")] + public string? Url { get; set; } + + [JsonPropertyName("info")] + public RoomAvatarInfo? Info { get; set; } + + public class RoomAvatarInfo { + [JsonPropertyName("h")] + public int? Height { get; set; } + + [JsonPropertyName("w")] + public int? Width { get; set; } + + [JsonPropertyName("mimetype")] + public string? MimeType { get; set; } + + [JsonPropertyName("size")] + public int? Size { get; set; } + } +} diff --git a/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomCanonicalAliasEventContent.cs b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomCanonicalAliasEventContent.cs new file mode 100644 index 0000000..046222e --- /dev/null +++ b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomCanonicalAliasEventContent.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; +using LibMatrix.Helpers; +using LibMatrix.Interfaces; + +namespace LibMatrix.EventTypes.Spec.State; + +[MatrixEvent(EventName = "m.room.canonical_alias")] +public class RoomCanonicalAliasEventContent : EventContent { + [JsonPropertyName("alias")] + public string? Alias { get; set; } + [JsonPropertyName("alt_aliases")] + public string[]? AltAliases { get; set; } +} diff --git a/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomCreateEventContent.cs b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomCreateEventContent.cs new file mode 100644 index 0000000..c5bf14e --- /dev/null +++ b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomCreateEventContent.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; +using LibMatrix.Helpers; +using LibMatrix.Interfaces; + +namespace LibMatrix.EventTypes.Spec.State; + +[MatrixEvent(EventName = "m.room.create")] +public class RoomCreateEventContent : EventContent { + [JsonPropertyName("room_version")] + public string? RoomVersion { get; set; } + + [JsonPropertyName("creator")] + public string? Creator { get; set; } + + [JsonPropertyName("m.federate")] + public bool? Federate { get; set; } + + [JsonPropertyName("predecessor")] + public RoomCreatePredecessor? Predecessor { get; set; } + + [JsonPropertyName("type")] + public string? Type { get; set; } + + public class RoomCreatePredecessor { + [JsonPropertyName("room_id")] + public string? RoomId { get; set; } + + [JsonPropertyName("event_id")] + public string? EventId { get; set; } + } +} diff --git a/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomEncryptionEventContent.cs b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomEncryptionEventContent.cs new file mode 100644 index 0000000..6ffa4c5 --- /dev/null +++ b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomEncryptionEventContent.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; +using LibMatrix.Helpers; +using LibMatrix.Interfaces; + +namespace LibMatrix.EventTypes.Spec.State; + +[MatrixEvent(EventName = "m.room.encryption")] +public class RoomEncryptionEventContent : EventContent { + [JsonPropertyName("algorithm")] + public string? Algorithm { get; set; } + [JsonPropertyName("rotation_period_ms")] + public ulong? RotationPeriodMs { get; set; } + [JsonPropertyName("rotation_period_msgs")] + public ulong? RotationPeriodMsgs { get; set; } +} diff --git a/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomGuestAccessEventContent.cs b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomGuestAccessEventContent.cs new file mode 100644 index 0000000..2bb4d36 --- /dev/null +++ b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomGuestAccessEventContent.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; +using LibMatrix.Helpers; +using LibMatrix.Interfaces; + +namespace LibMatrix.EventTypes.Spec.State; + +[MatrixEvent(EventName = "m.room.guest_access")] +public class RoomGuestAccessEventContent : EventContent { + [JsonPropertyName("guest_access")] + public string GuestAccess { get; set; } + + public bool IsGuestAccessEnabled { + get => GuestAccess == "can_join"; + set => GuestAccess = value ? "can_join" : "forbidden"; + } +} diff --git a/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomHistoryVisibilityEventContent.cs b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomHistoryVisibilityEventContent.cs new file mode 100644 index 0000000..a32fed2 --- /dev/null +++ b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomHistoryVisibilityEventContent.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; +using LibMatrix.Helpers; +using LibMatrix.Interfaces; + +namespace LibMatrix.EventTypes.Spec.State; + +[MatrixEvent(EventName = "m.room.history_visibility")] +public class RoomHistoryVisibilityEventContent : EventContent { + [JsonPropertyName("history_visibility")] + public string HistoryVisibility { get; set; } +} diff --git a/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomJoinRulesEventContent.cs b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomJoinRulesEventContent.cs new file mode 100644 index 0000000..2c2a91b --- /dev/null +++ b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomJoinRulesEventContent.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; +using LibMatrix.Helpers; +using LibMatrix.Interfaces; + +namespace LibMatrix.EventTypes.Spec.State; + +[MatrixEvent(EventName = "m.room.join_rules")] +public class RoomJoinRulesEventContent : EventContent { + private static string Public = "public"; + private static string Invite = "invite"; + private static string Knock = "knock"; + + /// + /// one of ["public", "invite", "knock", "restricted", "knock_restricted"] + /// "private" is reserved without implementation! + /// + [JsonPropertyName("join_rule")] + public string JoinRule { get; set; } + + [JsonPropertyName("allow")] + public List Allow { get; set; } + + public class AllowEntry { + [JsonPropertyName("type")] + public string Type { get; set; } + + [JsonPropertyName("room_id")] + public string RoomId { get; set; } + } +} diff --git a/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomMemberEventContent.cs b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomMemberEventContent.cs new file mode 100644 index 0000000..52cb293 --- /dev/null +++ b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomMemberEventContent.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; +using LibMatrix.Helpers; +using LibMatrix.Interfaces; + +namespace LibMatrix.EventTypes.Spec.State; + +[MatrixEvent(EventName = "m.room.member")] +public class RoomMemberEventContent : EventContent { + [JsonPropertyName("reason")] + public string? Reason { get; set; } + + [JsonPropertyName("membership")] + public string Membership { get; set; } = null!; + + [JsonPropertyName("displayname")] + public string? DisplayName { get; set; } + + [JsonPropertyName("is_direct")] + public bool? IsDirect { get; set; } + + [JsonPropertyName("avatar_url")] + public string? AvatarUrl { get; set; } + + [JsonPropertyName("kind")] + public string? Kind { get; set; } + + [JsonPropertyName("join_authorised_via_users_server")] + public string? JoinAuthorisedViaUsersServer { get; set; } +} diff --git a/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomNameEventContent.cs b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomNameEventContent.cs new file mode 100644 index 0000000..7cb881a --- /dev/null +++ b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomNameEventContent.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; +using LibMatrix.Helpers; +using LibMatrix.Interfaces; + +namespace LibMatrix.EventTypes.Spec.State; + +[MatrixEvent(EventName = "m.room.name")] +public class RoomNameEventContent : EventContent { + [JsonPropertyName("name")] + public string? Name { get; set; } +} diff --git a/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomPinnedEventContent.cs b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomPinnedEventContent.cs new file mode 100644 index 0000000..eb02cc7 --- /dev/null +++ b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomPinnedEventContent.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; +using LibMatrix.Helpers; +using LibMatrix.Interfaces; + +namespace LibMatrix.EventTypes.Spec.State; + +[MatrixEvent(EventName = "m.room.pinned_events")] +public class RoomPinnedEventContent : EventContent { + [JsonPropertyName("pinned")] + public string[]? PinnedEvents { get; set; } +} diff --git a/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomPowerLevelEventContent.cs b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomPowerLevelEventContent.cs new file mode 100644 index 0000000..2ae9593 --- /dev/null +++ b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomPowerLevelEventContent.cs @@ -0,0 +1,56 @@ +using System.Text.Json.Serialization; +using LibMatrix.Helpers; +using LibMatrix.Interfaces; + +namespace LibMatrix.EventTypes.Spec.State; + +[MatrixEvent(EventName = "m.room.power_levels")] +public class RoomPowerLevelEventContent : EventContent { + [JsonPropertyName("ban")] + public long? Ban { get; set; } = 50; + + [JsonPropertyName("events_default")] + public long EventsDefault { get; set; } = 0; + + [JsonPropertyName("events")] + public Dictionary? Events { get; set; } // = null!; + + [JsonPropertyName("invite")] + public long? Invite { get; set; } = 0; + + [JsonPropertyName("kick")] + public long? Kick { get; set; } = 50; + + [JsonPropertyName("notifications")] + public NotificationsPL? NotificationsPl { get; set; } // = null!; + + [JsonPropertyName("redact")] + public long? Redact { get; set; } = 50; + + [JsonPropertyName("state_default")] + public long? StateDefault { get; set; } = 50; + + [JsonPropertyName("users")] + public Dictionary? Users { get; set; } // = null!; + + [JsonPropertyName("users_default")] + public long? UsersDefault { get; set; } = 0; + + [Obsolete("Historical was a key related to MSC2716, a spec change on backfill that was dropped!", true)] + [JsonIgnore] + [JsonPropertyName("historical")] + public long Historical { get; set; } // = 50; + + public class NotificationsPL { + [JsonPropertyName("room")] + public long Room { get; set; } = 50; + } + + public bool IsUserAdmin(string userId) { + return Users.TryGetValue(userId, out var level) && level >= Events.Max(x => x.Value); + } + + public bool UserHasPermission(string userId, string eventType) { + return Users.TryGetValue(userId, out var level) && level >= Events.GetValueOrDefault(eventType, EventsDefault); + } +} diff --git a/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomServerACLEventContent.cs b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomServerACLEventContent.cs new file mode 100644 index 0000000..5c5627c --- /dev/null +++ b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomServerACLEventContent.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; +using LibMatrix.Helpers; +using LibMatrix.Interfaces; + +namespace LibMatrix.EventTypes.Spec.State; + +[MatrixEvent(EventName = "m.room.server_acl")] +public class RoomServerACLEventContent : EventContent { + [JsonPropertyName("allow")] + public List Allow { get; set; } // = null!; + + [JsonPropertyName("deny")] + public List Deny { get; set; } // = null!; + + [JsonPropertyName("allow_ip_literals")] + public bool AllowIpLiterals { get; set; } // = false; +} diff --git a/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomTopicEventContent.cs b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomTopicEventContent.cs new file mode 100644 index 0000000..52c7e42 --- /dev/null +++ b/LibMatrix/EventTypes/Spec/State/RoomInfo/RoomTopicEventContent.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; +using LibMatrix.Helpers; +using LibMatrix.Interfaces; + +namespace LibMatrix.EventTypes.Spec.State; + +[MatrixEvent(EventName = "m.room.topic")] +[MatrixEvent(EventName = "org.matrix.msc3765.topic", Legacy = true)] +public class RoomTopicEventContent : EventContent { + [JsonPropertyName("topic")] + public string? Topic { get; set; } +} diff --git a/LibMatrix/EventTypes/Spec/State/RoomMemberEventData.cs b/LibMatrix/EventTypes/Spec/State/RoomMemberEventData.cs deleted file mode 100644 index da158f1..0000000 --- a/LibMatrix/EventTypes/Spec/State/RoomMemberEventData.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Text.Json.Serialization; -using LibMatrix.Helpers; -using LibMatrix.Interfaces; - -namespace LibMatrix.EventTypes.Spec.State; - -[MatrixEvent(EventName = "m.room.member")] -public class RoomMemberEventContent : EventContent { - [JsonPropertyName("reason")] - public string? Reason { get; set; } - - [JsonPropertyName("membership")] - public string Membership { get; set; } = null!; - - [JsonPropertyName("displayname")] - public string? Displayname { get; set; } - - [JsonPropertyName("is_direct")] - public bool? IsDirect { get; set; } - - [JsonPropertyName("avatar_url")] - public string? AvatarUrl { get; set; } - - [JsonPropertyName("kind")] - public string? Kind { get; set; } - - [JsonPropertyName("join_authorised_via_users_server")] - public string? JoinAuthorisedViaUsersServer { get; set; } -} diff --git a/LibMatrix/EventTypes/Spec/State/RoomNameEventData.cs b/LibMatrix/EventTypes/Spec/State/RoomNameEventData.cs deleted file mode 100644 index 7cb881a..0000000 --- a/LibMatrix/EventTypes/Spec/State/RoomNameEventData.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Text.Json.Serialization; -using LibMatrix.Helpers; -using LibMatrix.Interfaces; - -namespace LibMatrix.EventTypes.Spec.State; - -[MatrixEvent(EventName = "m.room.name")] -public class RoomNameEventContent : EventContent { - [JsonPropertyName("name")] - public string? Name { get; set; } -} diff --git a/LibMatrix/EventTypes/Spec/State/RoomPinnedEventData.cs b/LibMatrix/EventTypes/Spec/State/RoomPinnedEventData.cs deleted file mode 100644 index eb02cc7..0000000 --- a/LibMatrix/EventTypes/Spec/State/RoomPinnedEventData.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Text.Json.Serialization; -using LibMatrix.Helpers; -using LibMatrix.Interfaces; - -namespace LibMatrix.EventTypes.Spec.State; - -[MatrixEvent(EventName = "m.room.pinned_events")] -public class RoomPinnedEventContent : EventContent { - [JsonPropertyName("pinned")] - public string[]? PinnedEvents { get; set; } -} diff --git a/LibMatrix/EventTypes/Spec/State/RoomPowerLevelEventData.cs b/LibMatrix/EventTypes/Spec/State/RoomPowerLevelEventData.cs deleted file mode 100644 index 2ae9593..0000000 --- a/LibMatrix/EventTypes/Spec/State/RoomPowerLevelEventData.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.Text.Json.Serialization; -using LibMatrix.Helpers; -using LibMatrix.Interfaces; - -namespace LibMatrix.EventTypes.Spec.State; - -[MatrixEvent(EventName = "m.room.power_levels")] -public class RoomPowerLevelEventContent : EventContent { - [JsonPropertyName("ban")] - public long? Ban { get; set; } = 50; - - [JsonPropertyName("events_default")] - public long EventsDefault { get; set; } = 0; - - [JsonPropertyName("events")] - public Dictionary? Events { get; set; } // = null!; - - [JsonPropertyName("invite")] - public long? Invite { get; set; } = 0; - - [JsonPropertyName("kick")] - public long? Kick { get; set; } = 50; - - [JsonPropertyName("notifications")] - public NotificationsPL? NotificationsPl { get; set; } // = null!; - - [JsonPropertyName("redact")] - public long? Redact { get; set; } = 50; - - [JsonPropertyName("state_default")] - public long? StateDefault { get; set; } = 50; - - [JsonPropertyName("users")] - public Dictionary? Users { get; set; } // = null!; - - [JsonPropertyName("users_default")] - public long? UsersDefault { get; set; } = 0; - - [Obsolete("Historical was a key related to MSC2716, a spec change on backfill that was dropped!", true)] - [JsonIgnore] - [JsonPropertyName("historical")] - public long Historical { get; set; } // = 50; - - public class NotificationsPL { - [JsonPropertyName("room")] - public long Room { get; set; } = 50; - } - - public bool IsUserAdmin(string userId) { - return Users.TryGetValue(userId, out var level) && level >= Events.Max(x => x.Value); - } - - public bool UserHasPermission(string userId, string eventType) { - return Users.TryGetValue(userId, out var level) && level >= Events.GetValueOrDefault(eventType, EventsDefault); - } -} diff --git a/LibMatrix/EventTypes/Spec/State/RoomTopicEventData.cs b/LibMatrix/EventTypes/Spec/State/RoomTopicEventData.cs deleted file mode 100644 index 52c7e42..0000000 --- a/LibMatrix/EventTypes/Spec/State/RoomTopicEventData.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Text.Json.Serialization; -using LibMatrix.Helpers; -using LibMatrix.Interfaces; - -namespace LibMatrix.EventTypes.Spec.State; - -[MatrixEvent(EventName = "m.room.topic")] -[MatrixEvent(EventName = "org.matrix.msc3765.topic", Legacy = true)] -public class RoomTopicEventContent : EventContent { - [JsonPropertyName("topic")] - public string? Topic { get; set; } -} diff --git a/LibMatrix/EventTypes/Spec/State/RoomTypingEventData.cs b/LibMatrix/EventTypes/Spec/State/RoomTypingEventData.cs deleted file mode 100644 index 01cfacf..0000000 --- a/LibMatrix/EventTypes/Spec/State/RoomTypingEventData.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Text.Json.Serialization; -using LibMatrix.Helpers; -using LibMatrix.Interfaces; - -namespace LibMatrix.EventTypes.Spec.State; - -[MatrixEvent(EventName = "m.typing")] -public class RoomTypingEventContent : EventContent { - [JsonPropertyName("user_ids")] - public string[]? UserIds { get; set; } -} diff --git a/LibMatrix/EventTypes/Spec/State/ServerACLEventData.cs b/LibMatrix/EventTypes/Spec/State/ServerACLEventData.cs deleted file mode 100644 index f18fe43..0000000 --- a/LibMatrix/EventTypes/Spec/State/ServerACLEventData.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Text.Json.Serialization; -using LibMatrix.Helpers; -using LibMatrix.Interfaces; - -namespace LibMatrix.EventTypes.Spec.State; - -[MatrixEvent(EventName = "m.room.server_acl")] -public class ServerACLEventContent : EventContent { - [JsonPropertyName("allow")] - public List Allow { get; set; } // = null!; - - [JsonPropertyName("deny")] - public List Deny { get; set; } // = null!; - - [JsonPropertyName("allow_ip_literals")] - public bool AllowIpLiterals { get; set; } // = false; -} diff --git a/LibMatrix/EventTypes/Spec/State/Space/SpaceChildEventContent.cs b/LibMatrix/EventTypes/Spec/State/Space/SpaceChildEventContent.cs new file mode 100644 index 0000000..0a897dc --- /dev/null +++ b/LibMatrix/EventTypes/Spec/State/Space/SpaceChildEventContent.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; +using LibMatrix.Helpers; +using LibMatrix.Interfaces; + +namespace LibMatrix.EventTypes.Spec.State; + +[MatrixEvent(EventName = "m.space.child")] +public class SpaceChildEventContent : EventContent { + [JsonPropertyName("auto_join")] + public bool? AutoJoin { get; set; } + [JsonPropertyName("via")] + public List? Via { get; set; } + [JsonPropertyName("suggested")] + public bool? Suggested { get; set; } +} diff --git a/LibMatrix/EventTypes/Spec/State/Space/SpaceParentEventContent.cs b/LibMatrix/EventTypes/Spec/State/Space/SpaceParentEventContent.cs new file mode 100644 index 0000000..0ffa193 --- /dev/null +++ b/LibMatrix/EventTypes/Spec/State/Space/SpaceParentEventContent.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; +using LibMatrix.Helpers; +using LibMatrix.Interfaces; + +namespace LibMatrix.EventTypes.Spec.State; + +[MatrixEvent(EventName = "m.space.parent")] +public class SpaceParentEventContent : EventContent { + [JsonPropertyName("via")] + public string[]? Via { get; set; } + + [JsonPropertyName("canonical")] + public bool? Canonical { get; set; } +} diff --git a/LibMatrix/EventTypes/Spec/State/SpaceChildEventData.cs b/LibMatrix/EventTypes/Spec/State/SpaceChildEventData.cs deleted file mode 100644 index 0a897dc..0000000 --- a/LibMatrix/EventTypes/Spec/State/SpaceChildEventData.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Text.Json.Serialization; -using LibMatrix.Helpers; -using LibMatrix.Interfaces; - -namespace LibMatrix.EventTypes.Spec.State; - -[MatrixEvent(EventName = "m.space.child")] -public class SpaceChildEventContent : EventContent { - [JsonPropertyName("auto_join")] - public bool? AutoJoin { get; set; } - [JsonPropertyName("via")] - public List? Via { get; set; } - [JsonPropertyName("suggested")] - public bool? Suggested { get; set; } -} diff --git a/LibMatrix/EventTypes/Spec/State/SpaceParentEventData.cs b/LibMatrix/EventTypes/Spec/State/SpaceParentEventData.cs deleted file mode 100644 index 0ffa193..0000000 --- a/LibMatrix/EventTypes/Spec/State/SpaceParentEventData.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Text.Json.Serialization; -using LibMatrix.Helpers; -using LibMatrix.Interfaces; - -namespace LibMatrix.EventTypes.Spec.State; - -[MatrixEvent(EventName = "m.space.parent")] -public class SpaceParentEventContent : EventContent { - [JsonPropertyName("via")] - public string[]? Via { get; set; } - - [JsonPropertyName("canonical")] - public bool? Canonical { get; set; } -} diff --git a/LibMatrix/EventTypes/UnknownStateEventContent.cs b/LibMatrix/EventTypes/UnknownStateEventContent.cs new file mode 100644 index 0000000..9a276c8 --- /dev/null +++ b/LibMatrix/EventTypes/UnknownStateEventContent.cs @@ -0,0 +1,7 @@ +using LibMatrix.Interfaces; + +namespace LibMatrix.EventTypes; + +public class UnknownEventContent : EventContent { + +} diff --git a/LibMatrix/EventTypes/UnknownStateEventData.cs b/LibMatrix/EventTypes/UnknownStateEventData.cs deleted file mode 100644 index 9a276c8..0000000 --- a/LibMatrix/EventTypes/UnknownStateEventData.cs +++ /dev/null @@ -1,7 +0,0 @@ -using LibMatrix.Interfaces; - -namespace LibMatrix.EventTypes; - -public class UnknownEventContent : EventContent { - -} diff --git a/LibMatrix/Extensions/EnumerableExtensions.cs b/LibMatrix/Extensions/EnumerableExtensions.cs new file mode 100644 index 0000000..d9619b7 --- /dev/null +++ b/LibMatrix/Extensions/EnumerableExtensions.cs @@ -0,0 +1,28 @@ +namespace LibMatrix.Extensions; + +public static class EnumerableExtensions { + public static void MergeStateEventLists(this List oldState, List newState) { + foreach (var stateEvent in newState) { + var old = oldState.FirstOrDefault(x => x.Type == stateEvent.Type && x.StateKey == stateEvent.StateKey); + if (old is null) { + oldState.Add(stateEvent); + continue; + } + oldState.Remove(old); + oldState.Add(stateEvent); + } + } + + public static void MergeStateEventLists(this List oldState, List newState) { + foreach (var stateEvent in newState) { + var old = oldState.FirstOrDefault(x => x.Type == stateEvent.Type && x.StateKey == stateEvent.StateKey); + if (old is null) { + oldState.Add(stateEvent); + continue; + } + oldState.Remove(old); + oldState.Add(stateEvent); + } + } + +} diff --git a/LibMatrix/Extensions/HttpClientExtensions.cs b/LibMatrix/Extensions/HttpClientExtensions.cs index a5eb40f..2fe99b6 100644 --- a/LibMatrix/Extensions/HttpClientExtensions.cs +++ b/LibMatrix/Extensions/HttpClientExtensions.cs @@ -68,7 +68,16 @@ public class MatrixHttpClient : HttpClient { var response = await SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken); - return await JsonSerializer.DeserializeAsync(responseStream, cancellationToken: cancellationToken); +#if DEBUG && false // This is only used for testing, so it's disabled by default + try { + await PostAsync("http://localhost:5116/validate/" + typeof(T).AssemblyQualifiedName, new StreamContent(responseStream), cancellationToken); + } + catch (Exception e) { + Console.WriteLine("[!!] Checking sync response failed: " + e); + } +#endif + return await JsonSerializer.DeserializeAsync(responseStream, cancellationToken: cancellationToken) ?? + throw new InvalidOperationException("Failed to deserialize response"); } // GetStreamAsync @@ -80,7 +89,8 @@ public class MatrixHttpClient : HttpClient { return await response.Content.ReadAsStreamAsync(cancellationToken); } - public new async Task PutAsJsonAsync([StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, T value, JsonSerializerOptions? options = null, CancellationToken cancellationToken = default) { + public new async Task PutAsJsonAsync([StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, T value, JsonSerializerOptions? options = null, + CancellationToken cancellationToken = default) { var request = new HttpRequestMessage(HttpMethod.Put, requestUri); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); request.Content = new StringContent(JsonSerializer.Serialize(value, value.GetType()), Encoding.UTF8, "application/json"); diff --git a/LibMatrix/Helpers/MatrixEventAttribute.cs b/LibMatrix/Helpers/MatrixEventAttribute.cs deleted file mode 100644 index 7efc039..0000000 --- a/LibMatrix/Helpers/MatrixEventAttribute.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace LibMatrix.Helpers; - -[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] -public class MatrixEventAttribute : Attribute { - public string EventName { get; set; } - public bool Legacy { get; set; } -} diff --git a/LibMatrix/Helpers/MessageFormatter.cs b/LibMatrix/Helpers/MessageFormatter.cs index ae02afc..d252e85 100644 --- a/LibMatrix/Helpers/MessageFormatter.cs +++ b/LibMatrix/Helpers/MessageFormatter.cs @@ -13,8 +13,7 @@ public static class MessageFormatter { public static RoomMessageEventContent FormatException(string error, Exception e) { return new RoomMessageEventContent(body: $"{error}: {e.Message}", messageType: "m.text") { - FormattedBody = $"{error}:
{e.Message}
" + - $"
", + FormattedBody = $"{error}:
{e.Message}
", Format = "org.matrix.custom.html" }; } @@ -36,4 +35,10 @@ public static class MessageFormatter { public static string HtmlFormatMention(string id, string? displayName = null) { return $"{displayName ?? id}"; } + +#region Extension functions + + public static RoomMessageEventContent ToMatrixMessage(this Exception e, string error) => FormatException(error, e); + +#endregion } diff --git a/LibMatrix/Helpers/SyncHelper.cs b/LibMatrix/Helpers/SyncHelper.cs index 74972a1..06ae3fe 100644 --- a/LibMatrix/Helpers/SyncHelper.cs +++ b/LibMatrix/Helpers/SyncHelper.cs @@ -1,228 +1,115 @@ +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Net.Http.Json; -using System.Text.Json.Serialization; using ArcaneLibs.Extensions; using LibMatrix.Filters; using LibMatrix.Homeservers; using LibMatrix.Responses; using LibMatrix.Services; +using Microsoft.Extensions.Logging; namespace LibMatrix.Helpers; -public class SyncHelper(AuthenticatedHomeserverGeneric homeserver) { - public async Task Sync( - string? since = null, - int? timeout = 30000, - string? setPresence = "online", - SyncFilter? filter = null, - CancellationToken? cancellationToken = null) { - var url = $"/_matrix/client/v3/sync?timeout={timeout}&set_presence={setPresence}"; - if (!string.IsNullOrWhiteSpace(since)) url += $"&since={since}"; - if (filter is not null) url += $"&filter={filter.ToJson(ignoreNull: true, indent: false)}"; - // else url += "&full_state=true"; - Console.WriteLine("Calling: " + url); - try { - var req = await homeserver._httpClient.GetAsync(url, cancellationToken: cancellationToken ?? CancellationToken.None); +public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logger = null) { + public string? Since { get; set; } + public int Timeout { get; set; } = 30000; + public string? SetPresence { get; set; } = "online"; + public SyncFilter? Filter { get; set; } + public bool FullState { get; set; } = false; -#if DEBUG && false - try { - await homeserver._httpClient.PostAsync( - "http://localhost:5116/validate/" + typeof(SyncResult).AssemblyQualifiedName, - new StreamContent(await req.Content.ReadAsStreamAsync())); - } - catch (Exception e) { - Console.WriteLine("[!!] Checking sync response failed: " + e); - } - var res = await req.Content.ReadFromJsonAsync(); - return res; -#else - return await req.Content.ReadFromJsonAsync(); -#endif + public async Task SyncAsync(CancellationToken? cancellationToken = null) { + var url = $"/_matrix/client/v3/sync?timeout={Timeout}&set_presence={SetPresence}&full_state={(FullState ? "true" : "false")}"; + if (!string.IsNullOrWhiteSpace(Since)) url += $"&since={Since}"; + if (Filter is not null) url += $"&filter={Filter.ToJson(ignoreNull: true, indent: false)}"; + // Console.WriteLine("Calling: " + url); + logger?.LogInformation("SyncHelper: Calling: {}", url); + try { + return await homeserver._httpClient.GetFromJsonAsync(url, cancellationToken: cancellationToken ?? CancellationToken.None); } catch (TaskCanceledException) { Console.WriteLine("Sync cancelled!"); + logger?.LogWarning("Sync cancelled due to TaskCanceledException!"); } catch (Exception e) { Console.WriteLine(e); + logger?.LogError(e, "Failed to sync!\n{}", e.ToString()); } return null; } - [SuppressMessage("ReSharper", "FunctionNeverReturns")] - public async Task RunSyncLoop( - bool skipInitialSyncEvents = true, - string? since = null, - int? timeout = 30000, - string? setPresence = "online", - SyncFilter? filter = null, - CancellationToken? cancellationToken = null - ) { - // await Task.WhenAll((await storageService.CacheStorageProvider.GetAllKeysAsync()) - // .Where(x => x.StartsWith("sync")) - // .ToList() - // .Select(x => storageService.CacheStorageProvider.DeleteObjectAsync(x))); - var nextBatch = since; - while (cancellationToken is null || !cancellationToken.Value.IsCancellationRequested) { - var sync = await Sync(since: nextBatch, timeout: timeout, setPresence: setPresence, filter: filter, - cancellationToken: cancellationToken); - nextBatch = sync?.NextBatch ?? nextBatch; + public async IAsyncEnumerable EnumerateSyncAsync(CancellationToken? cancellationToken = null) { + while(!cancellationToken?.IsCancellationRequested ?? true) { + var sync = await SyncAsync(cancellationToken); if (sync is null) continue; - Console.WriteLine($"Got sync, next batch: {nextBatch}!"); - - if (sync.Rooms is { Invite.Count: > 0 }) { - foreach (var roomInvite in sync.Rooms.Invite) { - var tasks = InviteReceivedHandlers.Select(x => x(roomInvite)).ToList(); - await Task.WhenAll(tasks); - } - } - - if (sync.AccountData is { Events: { Count: > 0 } }) { - foreach (var accountDataEvent in sync.AccountData.Events) { - var tasks = AccountDataReceivedHandlers.Select(x => x(accountDataEvent)).ToList(); - await Task.WhenAll(tasks); - } - } - - // Things that are skipped on the first sync - if (skipInitialSyncEvents) { - skipInitialSyncEvents = false; - continue; - } - - if (sync.Rooms is { Join.Count: > 0 }) { - foreach (var updatedRoom in sync.Rooms.Join) { - if(updatedRoom.Value.Timeline is null) continue; - foreach (var stateEventResponse in updatedRoom.Value.Timeline.Events) { - stateEventResponse.RoomId = updatedRoom.Key; - var tasks = TimelineEventHandlers.Select(x => { - try { - return x(stateEventResponse); - } - catch (Exception e) { - Console.WriteLine(e); - return Task.CompletedTask; - } - }).ToList(); - await Task.WhenAll(tasks); - } - } - } + Since = sync.NextBatch ?? Since; + yield return sync; } } - /// - /// Event fired when a room invite is received - /// - public List, Task>> - InviteReceivedHandlers { get; } = new(); - - public List> TimelineEventHandlers { get; } = new(); - public List> AccountDataReceivedHandlers { get; } = new(); -} - -public class SyncResult { - [JsonPropertyName("next_batch")] - public string NextBatch { get; set; } - - [JsonPropertyName("account_data")] - public EventList? AccountData { get; set; } - - [JsonPropertyName("presence")] - public PresenceDataStructure? Presence { get; set; } - - [JsonPropertyName("device_one_time_keys_count")] - public Dictionary DeviceOneTimeKeysCount { get; set; } - - [JsonPropertyName("rooms")] - public RoomsDataStructure? Rooms { get; set; } - - [JsonPropertyName("to_device")] - public EventList? ToDevice { get; set; } - - [JsonPropertyName("device_lists")] - public DeviceListsDataStructure? DeviceLists { get; set; } - - public class DeviceListsDataStructure { - [JsonPropertyName("changed")] - public List? Changed { get; set; } - - [JsonPropertyName("left")] - public List? Left { get; set; } - } - - // supporting classes - public class PresenceDataStructure { - [JsonPropertyName("events")] - public List Events { get; set; } + public async Task RunSyncLoopAsync(bool skipInitialSyncEvents = true, CancellationToken? cancellationToken = null) { + var sw = Stopwatch.StartNew(); + await foreach (var sync in EnumerateSyncAsync(cancellationToken)) { + logger?.LogInformation("Got sync response: {} bytes, {} elapsed", sync?.ToJson(ignoreNull: true, indent: false).Length ?? -1, sw.Elapsed); + await RunSyncLoopCallbacksAsync(sync, Since is null && skipInitialSyncEvents); + } } - public class RoomsDataStructure { - [JsonPropertyName("join")] - public Dictionary? Join { get; set; } - - [JsonPropertyName("invite")] - public Dictionary? Invite { get; set; } - - public class JoinedRoomDataStructure { - [JsonPropertyName("timeline")] - public TimelineDataStructure? Timeline { get; set; } - - [JsonPropertyName("state")] - public EventList State { get; set; } - - [JsonPropertyName("account_data")] - public EventList AccountData { get; set; } - - [JsonPropertyName("ephemeral")] - public EventList Ephemeral { get; set; } - - [JsonPropertyName("unread_notifications")] - public UnreadNotificationsDataStructure UnreadNotifications { get; set; } - - [JsonPropertyName("summary")] - public SummaryDataStructure Summary { get; set; } + private async Task RunSyncLoopCallbacksAsync(SyncResponse syncResponse, bool isInitialSync) { - public class TimelineDataStructure { - [JsonPropertyName("events")] - public List Events { get; set; } + var tasks = SyncReceivedHandlers.Select(x => x(syncResponse)).ToList(); + await Task.WhenAll(tasks); - [JsonPropertyName("prev_batch")] - public string PrevBatch { get; set; } - - [JsonPropertyName("limited")] - public bool Limited { get; set; } + if (syncResponse.AccountData is { Events: { Count: > 0 } }) { + foreach (var accountDataEvent in syncResponse.AccountData.Events) { + tasks = AccountDataReceivedHandlers.Select(x => x(accountDataEvent)).ToList(); + await Task.WhenAll(tasks); } + } - public class UnreadNotificationsDataStructure { - [JsonPropertyName("notification_count")] - public int NotificationCount { get; set; } + await RunSyncLoopRoomCallbacksAsync(syncResponse, isInitialSync); + } - [JsonPropertyName("highlight_count")] - public int HighlightCount { get; set; } + private async Task RunSyncLoopRoomCallbacksAsync(SyncResponse syncResponse, bool isInitialSync) { + if (syncResponse.Rooms is { Invite.Count: > 0 }) { + foreach (var roomInvite in syncResponse.Rooms.Invite) { + var tasks = InviteReceivedHandlers.Select(x => x(roomInvite)).ToList(); + await Task.WhenAll(tasks); } + } - public class SummaryDataStructure { - [JsonPropertyName("m.heroes")] - public List Heroes { get; set; } - - [JsonPropertyName("m.invited_member_count")] - public int InvitedMemberCount { get; set; } + if (isInitialSync) return; - [JsonPropertyName("m.joined_member_count")] - public int JoinedMemberCount { get; set; } + if (syncResponse.Rooms is { Join.Count: > 0 }) { + foreach (var updatedRoom in syncResponse.Rooms.Join) { + if (updatedRoom.Value.Timeline is null) continue; + foreach (var stateEventResponse in updatedRoom.Value.Timeline.Events) { + stateEventResponse.RoomId = updatedRoom.Key; + var tasks = TimelineEventHandlers.Select(x => x(stateEventResponse)).ToList(); + await Task.WhenAll(tasks); + } } } - - public class InvitedRoomDataStructure { - [JsonPropertyName("invite_state")] - public EventList InviteState { get; set; } - } } -} -public class EventList { - [JsonPropertyName("events")] - public List Events { get; set; } + /// + /// Event fired when a sync response is received + /// + public List> SyncReceivedHandlers { get; } = new(); + + /// + /// Event fired when a room invite is received + /// + public List, Task>> InviteReceivedHandlers { get; } = new(); + + /// + /// Event fired when a timeline event is received + /// + public List> TimelineEventHandlers { get; } = new(); + + /// + /// Event fired when an account data event is received + /// + public List> AccountDataReceivedHandlers { get; } = new(); } diff --git a/LibMatrix/Helpers/SyncStateResolver.cs b/LibMatrix/Helpers/SyncStateResolver.cs new file mode 100644 index 0000000..0070d60 --- /dev/null +++ b/LibMatrix/Helpers/SyncStateResolver.cs @@ -0,0 +1,174 @@ +using LibMatrix.Extensions; +using LibMatrix.Filters; +using LibMatrix.Homeservers; +using LibMatrix.Responses; +using Microsoft.Extensions.Logging; + +namespace LibMatrix.Helpers; + +public class SyncStateResolver(AuthenticatedHomeserverGeneric homeserver, ILogger? logger = null) { + public string? Since { get; set; } + public int Timeout { get; set; } = 30000; + public string? SetPresence { get; set; } = "online"; + public SyncFilter? Filter { get; set; } + public bool FullState { get; set; } = false; + + public SyncResponse? MergedState { get; set; } = null!; + + private SyncHelper _syncHelper = new SyncHelper(homeserver, logger); + + public async Task<(SyncResponse next, SyncResponse merged)> ContinueAsync(CancellationToken? cancellationToken = null) { + // copy properties + _syncHelper.Since = Since; + _syncHelper.Timeout = Timeout; + _syncHelper.SetPresence = SetPresence; + _syncHelper.Filter = Filter; + _syncHelper.FullState = FullState; + // run sync + var sync = await _syncHelper.SyncAsync(cancellationToken); + if (sync is null) return await ContinueAsync(cancellationToken); + if (MergedState is null) MergedState = sync; + else MergedState = MergeSyncs(MergedState, sync); + Since = sync.NextBatch; + return (sync, MergedState); + } + + private SyncResponse MergeSyncs(SyncResponse oldState, SyncResponse newState) { + oldState.NextBatch = newState.NextBatch ?? oldState.NextBatch; + + oldState.AccountData ??= new(); + oldState.AccountData.Events ??= new(); + if (newState.AccountData?.Events is not null) + oldState.AccountData.Events.MergeStateEventLists(newState.AccountData?.Events ?? new()); + + oldState.Presence ??= new(); + if (newState.Presence?.Events is not null) + oldState.Presence.Events.MergeStateEventLists(newState.Presence?.Events ?? new()); + + oldState.DeviceOneTimeKeysCount ??= new(); + if (newState.DeviceOneTimeKeysCount is not null) + foreach (var (key, value) in newState.DeviceOneTimeKeysCount) { + oldState.DeviceOneTimeKeysCount[key] = value; + } + + oldState.Rooms ??= new(); + if (newState.Rooms is not null) + oldState.Rooms = MergeRoomsDataStructure(oldState.Rooms, newState.Rooms); + + oldState.ToDevice ??= new(); + oldState.ToDevice.Events ??= new(); + if (newState.ToDevice?.Events is not null) + oldState.ToDevice.Events.MergeStateEventLists(newState.ToDevice?.Events ?? new()); + + oldState.DeviceLists ??= new(); + if (newState.DeviceLists?.Changed is not null) + foreach (var s in oldState.DeviceLists.Changed!) { + oldState.DeviceLists.Changed.Add(s); + } + if (newState.DeviceLists?.Left is not null) + foreach (var s in oldState.DeviceLists.Left!) { + oldState.DeviceLists.Left.Add(s); + } + + + return oldState; + } + +#region Merge rooms + + private SyncResponse.RoomsDataStructure MergeRoomsDataStructure(SyncResponse.RoomsDataStructure oldState, SyncResponse.RoomsDataStructure newState) { + oldState.Join ??= new(); + foreach (var (key, value) in newState.Join ?? new()) { + if (!oldState.Join.ContainsKey(key)) oldState.Join[key] = value; + else oldState.Join[key] = MergeJoinedRoomDataStructure(oldState.Join[key], value); + } + + oldState.Invite ??= new(); + foreach (var (key, value) in newState.Invite ?? new()) { + if (!oldState.Invite.ContainsKey(key)) oldState.Invite[key] = value; + else oldState.Invite[key] = MergeInvitedRoomDataStructure(oldState.Invite[key], value); + } + + oldState.Leave ??= new(); + foreach (var (key, value) in newState.Leave ?? new()) { + if (!oldState.Leave.ContainsKey(key)) oldState.Leave[key] = value; + else oldState.Leave[key] = MergeLeftRoomDataStructure(oldState.Leave[key], value); + if (oldState.Invite.ContainsKey(key)) oldState.Invite.Remove(key); + if (oldState.Join.ContainsKey(key)) oldState.Join.Remove(key); + } + + return oldState; + } + + private SyncResponse.RoomsDataStructure.LeftRoomDataStructure MergeLeftRoomDataStructure(SyncResponse.RoomsDataStructure.LeftRoomDataStructure oldData, + SyncResponse.RoomsDataStructure.LeftRoomDataStructure newData) { + oldData.AccountData ??= new(); + oldData.AccountData.Events ??= new(); + oldData.Timeline ??= new(); + oldData.Timeline.Events ??= new(); + oldData.State ??= new(); + oldData.State.Events ??= new(); + + if (newData.AccountData?.Events is not null) + oldData.AccountData.Events.MergeStateEventLists(newData.AccountData?.Events ?? new()); + + if (newData.Timeline?.Events is not null) + oldData.Timeline.Events.MergeStateEventLists(newData.Timeline?.Events ?? new()); + oldData.Timeline.Limited = newData.Timeline?.Limited ?? oldData.Timeline.Limited; + oldData.Timeline.PrevBatch = newData.Timeline?.PrevBatch ?? oldData.Timeline.PrevBatch; + + if (newData.State?.Events is not null) + oldData.State.Events.MergeStateEventLists(newData.State?.Events ?? new()); + + return oldData; + } + + private SyncResponse.RoomsDataStructure.InvitedRoomDataStructure MergeInvitedRoomDataStructure(SyncResponse.RoomsDataStructure.InvitedRoomDataStructure oldData, + SyncResponse.RoomsDataStructure.InvitedRoomDataStructure newData) { + oldData.InviteState ??= new(); + oldData.InviteState.Events ??= new(); + if (newData.InviteState?.Events is not null) + oldData.InviteState.Events.MergeStateEventLists(newData.InviteState?.Events ?? new()); + + return oldData; + } + + private SyncResponse.RoomsDataStructure.JoinedRoomDataStructure MergeJoinedRoomDataStructure(SyncResponse.RoomsDataStructure.JoinedRoomDataStructure oldData, + SyncResponse.RoomsDataStructure.JoinedRoomDataStructure newData) { + oldData.AccountData ??= new(); + oldData.AccountData.Events ??= new(); + oldData.Timeline ??= new(); + oldData.Timeline.Events ??= new(); + oldData.State ??= new(); + oldData.State.Events ??= new(); + oldData.Ephemeral ??= new(); + oldData.Ephemeral.Events ??= new(); + + if (newData.AccountData?.Events is not null) + oldData.AccountData.Events.MergeStateEventLists(newData.AccountData?.Events ?? new()); + + if (newData.Timeline?.Events is not null) + oldData.Timeline.Events.MergeStateEventLists(newData.Timeline?.Events ?? new()); + oldData.Timeline.Limited = newData.Timeline?.Limited ?? oldData.Timeline.Limited; + oldData.Timeline.PrevBatch = newData.Timeline?.PrevBatch ?? oldData.Timeline.PrevBatch; + + if (newData.State?.Events is not null) + oldData.State.Events.MergeStateEventLists(newData.State?.Events ?? new()); + + if (newData.Ephemeral?.Events is not null) + oldData.Ephemeral.Events.MergeStateEventLists(newData.Ephemeral?.Events ?? new()); + + oldData.UnreadNotifications ??= new(); + oldData.UnreadNotifications.HighlightCount = newData.UnreadNotifications?.HighlightCount ?? oldData.UnreadNotifications.HighlightCount; + oldData.UnreadNotifications.NotificationCount = newData.UnreadNotifications?.NotificationCount ?? oldData.UnreadNotifications.NotificationCount; + + oldData.Summary ??= new(); + oldData.Summary.Heroes = newData.Summary?.Heroes ?? oldData.Summary.Heroes; + oldData.Summary.JoinedMemberCount = newData.Summary?.JoinedMemberCount ?? oldData.Summary.JoinedMemberCount; + oldData.Summary.InvitedMemberCount = newData.Summary?.InvitedMemberCount ?? oldData.Summary.InvitedMemberCount; + + return oldData; + } + +#endregion +} diff --git a/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs b/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs index f70dd39..d5b0a77 100644 --- a/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs +++ b/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs @@ -12,19 +12,35 @@ using LibMatrix.Services; namespace LibMatrix.Homeservers; -public class AuthenticatedHomeserverGeneric : RemoteHomeServer { - public AuthenticatedHomeserverGeneric(string baseUrl, string accessToken) : base(baseUrl) { - AccessToken = accessToken.Trim(); - SyncHelper = new SyncHelper(this); - - _httpClient.Timeout = TimeSpan.FromMinutes(15); - _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); +public class AuthenticatedHomeserverGeneric(string baseUrl, string accessToken) : RemoteHomeServer(baseUrl) { + public static async Task Create(string baseUrl, string accessToken) where T : AuthenticatedHomeserverGeneric { + var instance = Activator.CreateInstance(typeof(T), baseUrl, accessToken) as T + ?? throw new InvalidOperationException($"Failed to create instance of {typeof(T).Name}"); + instance._httpClient = new() { + BaseAddress = new Uri(await new HomeserverResolverService().ResolveHomeserverFromWellKnown(baseUrl) + ?? throw new InvalidOperationException("Failed to resolve homeserver")), + Timeout = TimeSpan.FromMinutes(15), + DefaultRequestHeaders = { + Authorization = new AuthenticationHeaderValue("Bearer", accessToken) + } + }; + instance.WhoAmI = await instance._httpClient.GetFromJsonAsync("/_matrix/client/v3/account/whoami"); + return instance; } - public virtual SyncHelper SyncHelper { get; init; } - private WhoAmIResponse? _whoAmI; + // Activator.CreateInstance(baseUrl, accessToken) { + // _httpClient = new() { + // BaseAddress = new Uri(await new HomeserverResolverService().ResolveHomeserverFromWellKnown(baseUrl) + // ?? throw new InvalidOperationException("Failed to resolve homeserver")), + // Timeout = TimeSpan.FromMinutes(15), + // DefaultRequestHeaders = { + // Authorization = new AuthenticationHeaderValue("Bearer", accessToken) + // } + // } + // }; + - public WhoAmIResponse? WhoAmI => _whoAmI ??= _httpClient.GetFromJsonAsync("/_matrix/client/v3/account/whoami").Result; + public WhoAmIResponse? WhoAmI { get; set; } public string UserId => WhoAmI.UserId; // public virtual async Task WhoAmI() { @@ -33,9 +49,9 @@ public class AuthenticatedHomeserverGeneric : RemoteHomeServer { // return _whoAmI; // } - public virtual string AccessToken { get; set; } + public string AccessToken { get; set; } = accessToken; - public virtual GenericRoom GetRoom(string roomId) { + public GenericRoom GetRoom(string roomId) { if (roomId is null || !roomId.StartsWith("!")) throw new ArgumentException("Room ID must start with !", nameof(roomId)); return new GenericRoom(this, roomId); } @@ -112,7 +128,7 @@ public class AuthenticatedHomeserverGeneric : RemoteHomeServer { #region Account Data - public virtual async Task GetAccountData(string key) { + public virtual async Task GetAccountDataAsync(string key) { // var res = await _httpClient.GetAsync($"/_matrix/client/v3/user/{UserId}/account_data/{key}"); // if (!res.IsSuccessStatusCode) { // Console.WriteLine($"Failed to get account data: {await res.Content.ReadAsStringAsync()}"); diff --git a/LibMatrix/Homeservers/RemoteHomeServer.cs b/LibMatrix/Homeservers/RemoteHomeServer.cs index d10c837..798349a 100644 --- a/LibMatrix/Homeservers/RemoteHomeServer.cs +++ b/LibMatrix/Homeservers/RemoteHomeServer.cs @@ -1,3 +1,4 @@ +using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; @@ -10,13 +11,18 @@ using LibMatrix.Services; namespace LibMatrix.Homeservers; public class RemoteHomeServer(string baseUrl) { + public static async Task Create(string baseUrl) => + new(baseUrl) { + _httpClient = new() { + BaseAddress = new Uri(await new HomeserverResolverService().ResolveHomeserverFromWellKnown(baseUrl) + ?? throw new InvalidOperationException("Failed to resolve homeserver")), + Timeout = TimeSpan.FromSeconds(120) + } + }; private Dictionary _profileCache { get; set; } = new(); - public string BaseUrl { get; } = baseUrl.Trim(); - public MatrixHttpClient _httpClient { get; set; } = new() { - BaseAddress = new Uri(new HomeserverResolverService().ResolveHomeserverFromWellKnown(baseUrl).Result ?? throw new InvalidOperationException("Failed to resolve homeserver")), - Timeout = TimeSpan.FromSeconds(120) - }; + public string BaseUrl { get; } = baseUrl; + public MatrixHttpClient _httpClient { get; set; } public async Task GetProfileAsync(string mxid) { if (mxid is null) throw new ArgumentNullException(nameof(mxid)); diff --git a/LibMatrix/Interfaces/EventContent.cs b/LibMatrix/Interfaces/EventContent.cs new file mode 100644 index 0000000..b21cfc7 --- /dev/null +++ b/LibMatrix/Interfaces/EventContent.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; + +namespace LibMatrix.Interfaces; + +public abstract class EventContent { + [JsonPropertyName("m.relates_to")] + public MessageRelatesTo? RelatesTo { get; set; } + + [JsonPropertyName("m.new_content")] + public EventContent? NewContent { get; set; } + + public class MessageRelatesTo { + [JsonPropertyName("m.in_reply_to")] + public EventInReplyTo? InReplyTo { get; set; } + + + + public abstract class EventInReplyTo { + [JsonPropertyName("event_id")] + public string EventId { get; set; } + + [JsonPropertyName("rel_type")] + public string RelType { get; set; } + } + } +} diff --git a/LibMatrix/Interfaces/IStateEventType.cs b/LibMatrix/Interfaces/IStateEventType.cs deleted file mode 100644 index b187970..0000000 --- a/LibMatrix/Interfaces/IStateEventType.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Text.Json.Serialization; - -namespace LibMatrix.Interfaces; - -public abstract class EventContent { - [JsonPropertyName("m.relates_to")] - public MessageRelatesTo? RelatesTo { get; set; } - - [JsonPropertyName("m.new_content")] - public EventContent? NewContent { get; set; } - - public abstract class MessageRelatesTo { - [JsonPropertyName("m.in_reply_to")] - public EventInReplyTo? InReplyTo { get; set; } - - - - public abstract class EventInReplyTo { - [JsonPropertyName("event_id")] - public string EventId { get; set; } - } - } -} diff --git a/LibMatrix/Responses/CreateRoomRequest.cs b/LibMatrix/Responses/CreateRoomRequest.cs index 511b3da..1ad590f 100644 --- a/LibMatrix/Responses/CreateRoomRequest.cs +++ b/LibMatrix/Responses/CreateRoomRequest.cs @@ -2,6 +2,7 @@ using System.Reflection; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Text.RegularExpressions; +using LibMatrix.EventTypes; using LibMatrix.EventTypes.Spec.State; using LibMatrix.Helpers; using LibMatrix.Homeservers; diff --git a/LibMatrix/Responses/LoginResponse.cs b/LibMatrix/Responses/LoginResponse.cs index eb53c0a..07b1601 100644 --- a/LibMatrix/Responses/LoginResponse.cs +++ b/LibMatrix/Responses/LoginResponse.cs @@ -23,7 +23,7 @@ public class LoginResponse { public string UserId { get; set; } = null!; public async Task GetAuthenticatedHomeserver(string? proxy = null) { - return new AuthenticatedHomeserverGeneric(proxy ?? await new HomeserverResolverService().ResolveHomeserverFromWellKnown(Homeserver), AccessToken); + return await AuthenticatedHomeserverGeneric.Create(proxy ?? await new HomeserverResolverService().ResolveHomeserverFromWellKnown(Homeserver), AccessToken); } } public class LoginRequest { diff --git a/LibMatrix/Responses/StateEventResponse.cs b/LibMatrix/Responses/StateEventResponse.cs deleted file mode 100644 index 7ca6bab..0000000 --- a/LibMatrix/Responses/StateEventResponse.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; - -namespace LibMatrix.Responses; - -public class StateEventResponse : StateEvent { - [JsonPropertyName("origin_server_ts")] - public ulong OriginServerTs { get; set; } - - [JsonPropertyName("room_id")] - public string RoomId { get; set; } - - [JsonPropertyName("sender")] - public string Sender { get; set; } - - [JsonPropertyName("unsigned")] - public UnsignedData? Unsigned { get; set; } - - [JsonPropertyName("event_id")] - public string EventId { get; set; } - - [JsonPropertyName("user_id")] - public string UserId { get; set; } - - [JsonPropertyName("replaces_state")] - public new string ReplacesState { get; set; } - - public class UnsignedData { - [JsonPropertyName("age")] - public ulong? Age { get; set; } - - [JsonPropertyName("redacted_because")] - public object? RedactedBecause { get; set; } - - [JsonPropertyName("transaction_id")] - public string? TransactionId { get; set; } - - [JsonPropertyName("replaces_state")] - public string? ReplacesState { get; set; } - - [JsonPropertyName("prev_sender")] - public string? PrevSender { get; set; } - - [JsonPropertyName("prev_content")] - public JsonObject? PrevContent { get; set; } - } -} - -public class ChunkedStateEventResponse { - [JsonPropertyName("chunk")] - public List? Chunk { get; set; } -} diff --git a/LibMatrix/Responses/SyncResponse.cs b/LibMatrix/Responses/SyncResponse.cs new file mode 100644 index 0000000..39cb38f --- /dev/null +++ b/LibMatrix/Responses/SyncResponse.cs @@ -0,0 +1,118 @@ +using System.Text.Json.Serialization; +using LibMatrix.Helpers; + +namespace LibMatrix.Responses; + +public class SyncResponse { + [JsonPropertyName("next_batch")] + public string NextBatch { get; set; } = null!; + + [JsonPropertyName("account_data")] + public EventList? AccountData { get; set; } + + [JsonPropertyName("presence")] + public PresenceDataStructure? Presence { get; set; } + + [JsonPropertyName("device_one_time_keys_count")] + public Dictionary? DeviceOneTimeKeysCount { get; set; } = null!; + + [JsonPropertyName("rooms")] + public RoomsDataStructure? Rooms { get; set; } + + [JsonPropertyName("to_device")] + public EventList? ToDevice { get; set; } + + [JsonPropertyName("device_lists")] + public DeviceListsDataStructure? DeviceLists { get; set; } + + public class DeviceListsDataStructure { + [JsonPropertyName("changed")] + public List? Changed { get; set; } + + [JsonPropertyName("left")] + public List? Left { get; set; } + } + + // supporting classes + public class PresenceDataStructure { + [JsonPropertyName("events")] + public List Events { get; set; } = new(); + } + + public class RoomsDataStructure { + [JsonPropertyName("join")] + public Dictionary? Join { get; set; } + + [JsonPropertyName("invite")] + public Dictionary? Invite { get; set; } + + [JsonPropertyName("leave")] + public Dictionary? Leave { get; set; } + + public class LeftRoomDataStructure { + [JsonPropertyName("account_data")] + public EventList AccountData { get; set; } + + [JsonPropertyName("timeline")] + public JoinedRoomDataStructure.TimelineDataStructure? Timeline { get; set; } + + [JsonPropertyName("state")] + public EventList State { get; set; } + } + + public class JoinedRoomDataStructure { + [JsonPropertyName("timeline")] + public TimelineDataStructure? Timeline { get; set; } + + [JsonPropertyName("state")] + public EventList? State { get; set; } + + [JsonPropertyName("account_data")] + public EventList? AccountData { get; set; } + + [JsonPropertyName("ephemeral")] + public EventList? Ephemeral { get; set; } + + [JsonPropertyName("unread_notifications")] + public UnreadNotificationsDataStructure? UnreadNotifications { get; set; } + + [JsonPropertyName("summary")] + public SummaryDataStructure? Summary { get; set; } + + public class TimelineDataStructure { + [JsonPropertyName("events")] + public List? Events { get; set; } + + [JsonPropertyName("prev_batch")] + public string? PrevBatch { get; set; } + + [JsonPropertyName("limited")] + public bool? Limited { get; set; } + } + + public class UnreadNotificationsDataStructure { + [JsonPropertyName("notification_count")] + public int NotificationCount { get; set; } + + [JsonPropertyName("highlight_count")] + public int HighlightCount { get; set; } + } + + public class SummaryDataStructure { + [JsonPropertyName("m.heroes")] + public List Heroes { get; set; } + + [JsonPropertyName("m.invited_member_count")] + public int InvitedMemberCount { get; set; } + + [JsonPropertyName("m.joined_member_count")] + public int JoinedMemberCount { get; set; } + } + } + + public class InvitedRoomDataStructure { + [JsonPropertyName("invite_state")] + public EventList? InviteState { get; set; } + } + } +} diff --git a/LibMatrix/RoomTypes/GenericRoom.cs b/LibMatrix/RoomTypes/GenericRoom.cs index 78a0873..75cb5f3 100644 --- a/LibMatrix/RoomTypes/GenericRoom.cs +++ b/LibMatrix/RoomTypes/GenericRoom.cs @@ -118,16 +118,16 @@ public class GenericRoom { #region Utility shortcuts - public async Task SendMessageEventAsync(RoomMessageEventContent content) => + public async Task SendMessageEventAsync(RoomMessageEventContent content) => await SendTimelineEventAsync("m.room.message", content); - public async Task> GetAliasesAsync() { + public async Task?> GetAliasesAsync() { var res = await GetStateAsync("m.room.aliases"); return res.Aliases; } - public async Task GetCanonicalAliasAsync() => - await GetStateAsync("m.room.canonical_alias"); + public async Task GetCanonicalAliasAsync() => + await GetStateAsync("m.room.canonical_alias"); public async Task GetTopicAsync() => await GetStateAsync("m.room.topic"); @@ -135,16 +135,16 @@ public class GenericRoom { public async Task GetAvatarUrlAsync() => await GetStateAsync("m.room.avatar"); - public async Task GetJoinRuleAsync() => - await GetStateAsync("m.room.join_rules"); + public async Task GetJoinRuleAsync() => + await GetStateAsync("m.room.join_rules"); - public async Task GetHistoryVisibilityAsync() => - await GetStateAsync("m.room.history_visibility"); + public async Task GetHistoryVisibilityAsync() => + await GetStateAsync("m.room.history_visibility"); - public async Task GetGuestAccessAsync() => - await GetStateAsync("m.room.guest_access"); + public async Task GetGuestAccessAsync() => + await GetStateAsync("m.room.guest_access"); - public async Task GetCreateEventAsync() => + public async Task GetCreateEventAsync() => await GetStateAsync("m.room.create"); public async Task GetRoomType() { @@ -177,24 +177,23 @@ public class GenericRoom { await _httpClient.PostAsJsonAsync($"/_matrix/client/v3/rooms/{RoomId}/unban", new UserIdAndReason { UserId = userId }); - public async Task SendStateEventAsync(string eventType, object content) => + public async Task SendStateEventAsync(string eventType, object content) => await (await _httpClient.PutAsJsonAsync($"/_matrix/client/v3/rooms/{RoomId}/state/{eventType}", content)) .Content.ReadFromJsonAsync(); - public async Task SendStateEventAsync(string eventType, string stateKey, object content) => + public async Task SendStateEventAsync(string eventType, string stateKey, object content) => await (await _httpClient.PutAsJsonAsync($"/_matrix/client/v3/rooms/{RoomId}/state/{eventType}/{stateKey}", content)) .Content.ReadFromJsonAsync(); - public async Task SendTimelineEventAsync(string eventType, EventContent content) { + public async Task SendTimelineEventAsync(string eventType, EventContent content) { var res = await _httpClient.PutAsJsonAsync( $"/_matrix/client/v3/rooms/{RoomId}/send/{eventType}/" + Guid.NewGuid(), content, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }); - var resu = await res.Content.ReadFromJsonAsync(); - return resu; + return await res.Content.ReadFromJsonAsync(); } - public async Task SendFileAsync(string fileName, Stream fileStream, string messageType = "m.file") { + public async Task SendFileAsync(string fileName, Stream fileStream, string messageType = "m.file") { var url = await Homeserver.UploadFile(fileName, fileStream); var content = new RoomMessageEventContent() { MessageType = messageType, @@ -205,7 +204,7 @@ public class GenericRoom { return await SendTimelineEventAsync("m.room.message", content); } - public async Task GetRoomAccountDataAsync(string key) { + public async Task GetRoomAccountDataAsync(string key) { var res = await _httpClient.GetAsync($"/_matrix/client/v3/user/{Homeserver.UserId}/rooms/{RoomId}/account_data/{key}"); if (!res.IsSuccessStatusCode) { Console.WriteLine($"Failed to get room account data: {await res.Content.ReadAsStringAsync()}"); diff --git a/LibMatrix/Services/HomeserverProviderService.cs b/LibMatrix/Services/HomeserverProviderService.cs index 666d2a2..1f3bd37 100644 --- a/LibMatrix/Services/HomeserverProviderService.cs +++ b/LibMatrix/Services/HomeserverProviderService.cs @@ -35,20 +35,15 @@ public class HomeserverProviderService { } var domain = proxy ?? await _homeserverResolverService.ResolveHomeserverFromWellKnown(homeserver); - var hc = new MatrixHttpClient { BaseAddress = new Uri(domain) }; AuthenticatedHomeserverGeneric hs; if (true) { - hs = new AuthenticatedHomeserverMxApiExtended(homeserver, accessToken); + hs = await AuthenticatedHomeserverGeneric.Create(homeserver, accessToken); } else { - hs = new AuthenticatedHomeserverGeneric(homeserver, accessToken); + hs = await AuthenticatedHomeserverGeneric.Create(homeserver, accessToken); } - hs._httpClient = hc; - hs._httpClient.Timeout = TimeSpan.FromMinutes(15); - hs._httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); - // (() => hs.WhoAmI) = (await hs._httpClient.GetFromJsonAsync("/_matrix/client/v3/account/whoami"))!; lock(_authenticatedHomeServerCache) @@ -59,7 +54,7 @@ public class HomeserverProviderService { } public async Task GetRemoteHomeserver(string homeserver, string? proxy = null) { - var hs = new RemoteHomeServer(proxy ?? await _homeserverResolverService.ResolveHomeserverFromWellKnown(homeserver)); + var hs = await RemoteHomeServer.Create(proxy ?? await _homeserverResolverService.ResolveHomeserverFromWellKnown(homeserver)); // hs._httpClient.Dispose(); // hs._httpClient = new MatrixHttpClient { BaseAddress = new Uri(hs.FullHomeServerDomain) }; // hs._httpClient.Timeout = TimeSpan.FromSeconds(120); diff --git a/LibMatrix/Services/HomeserverResolverService.cs b/LibMatrix/Services/HomeserverResolverService.cs index 685724b..75545db 100644 --- a/LibMatrix/Services/HomeserverResolverService.cs +++ b/LibMatrix/Services/HomeserverResolverService.cs @@ -12,6 +12,9 @@ public class HomeserverResolverService(ILogger? logge private static readonly Dictionary _wellKnownSemaphores = new(); public async Task ResolveHomeserverFromWellKnown(string homeserver) { + if (homeserver is null) throw new ArgumentNullException(nameof(homeserver)); + if(_wellKnownCache.TryGetValue(homeserver, out var known)) return known; + logger?.LogInformation("Resolving homeserver: {}", homeserver); var res = await _resolveHomeserverFromWellKnown(homeserver); if (!res.StartsWith("http")) res = "https://" + res; if (res.EndsWith(":443")) res = res[..^4]; @@ -21,6 +24,7 @@ public class HomeserverResolverService(ILogger? logge private async Task _resolveHomeserverFromWellKnown(string homeserver) { if (homeserver is null) throw new ArgumentNullException(nameof(homeserver)); var sem = _wellKnownSemaphores.GetOrCreate(homeserver, _ => new SemaphoreSlim(1, 1)); + if(_wellKnownCache.TryGetValue(homeserver, out var wellKnown)) return wellKnown; await sem.WaitAsync(); if (_wellKnownCache.TryGetValue(homeserver, out var known)) { sem.Release(); diff --git a/LibMatrix/StateEvent.cs b/LibMatrix/StateEvent.cs index b42bd64..c51fadb 100644 --- a/LibMatrix/StateEvent.cs +++ b/LibMatrix/StateEvent.cs @@ -39,8 +39,11 @@ public class StateEvent { public EventContent TypedContent { get { + if(Type == "m.receipt") { + return null!; + } try { - return (EventContent) RawContent.Deserialize(GetType)!; + return (EventContent)RawContent.Deserialize(GetType)!; } catch (JsonException e) { Console.WriteLine(e); @@ -122,6 +125,61 @@ public class StateEvent { public string cdtype => TypedContent.GetType().Name; } +public class StateEventResponse : StateEvent { + [JsonPropertyName("origin_server_ts")] + public ulong OriginServerTs { get; set; } + + [JsonPropertyName("room_id")] + public string RoomId { get; set; } + + [JsonPropertyName("sender")] + public string Sender { get; set; } + + [JsonPropertyName("unsigned")] + public UnsignedData? Unsigned { get; set; } + + [JsonPropertyName("event_id")] + public string EventId { get; set; } + + [JsonPropertyName("user_id")] + public string UserId { get; set; } + + [JsonPropertyName("replaces_state")] + public new string ReplacesState { get; set; } + + public class UnsignedData { + [JsonPropertyName("age")] + public ulong? Age { get; set; } + + [JsonPropertyName("redacted_because")] + public object? RedactedBecause { get; set; } + + [JsonPropertyName("transaction_id")] + public string? TransactionId { get; set; } + + [JsonPropertyName("replaces_state")] + public string? ReplacesState { get; set; } + + [JsonPropertyName("prev_sender")] + public string? PrevSender { get; set; } + + [JsonPropertyName("prev_content")] + public JsonObject? PrevContent { get; set; } + } +} + +public class EventList { + [JsonPropertyName("events")] + public List? Events { get; set; } = new(); +} + +public class ChunkedStateEventResponse { + [JsonPropertyName("chunk")] + public List? Chunk { get; set; } = new(); +} + +#region Unused code + /* public class StateEventContentPolymorphicTypeInfoResolver : DefaultJsonTypeInfoResolver { @@ -150,3 +208,5 @@ public class StateEventContentPolymorphicTypeInfoResolver : DefaultJsonTypeInfoR } } */ + +#endregion diff --git a/Tests/LibMatrix.Tests/Abstractions/HomeserverAbstraction.cs b/Tests/LibMatrix.Tests/Abstractions/HomeserverAbstraction.cs index abd3e99..e23d4f4 100644 --- a/Tests/LibMatrix.Tests/Abstractions/HomeserverAbstraction.cs +++ b/Tests/LibMatrix.Tests/Abstractions/HomeserverAbstraction.cs @@ -6,7 +6,7 @@ namespace LibMatrix.Tests.Abstractions; public static class HomeserverAbstraction { public static async Task GetHomeserver() { - var rhs = new RemoteHomeServer("https://matrixunittests.rory.gay"); + var rhs = await RemoteHomeServer.Create("https://matrixunittests.rory.gay"); // string username = Guid.NewGuid().ToString(); // string password = Guid.NewGuid().ToString(); string username = "@f1a2d2d6-1924-421b-91d0-893b347b2a49:matrixunittests.rory.gay"; @@ -25,15 +25,15 @@ public static class HomeserverAbstraction { var hs = await reg.GetAuthenticatedHomeserver("https://matrixunittests.rory.gay"); - var rooms = await hs.GetJoinedRooms(); + //var rooms = await hs.GetJoinedRooms(); - var disbandRoomTasks = rooms.Select(async room => { - // await room.DisbandRoomAsync(); - await room.LeaveAsync(); - await room.ForgetAsync(); - return room; - }).ToList(); - await Task.WhenAll(disbandRoomTasks); + // var disbandRoomTasks = rooms.Select(async room => { + // // await room.DisbandRoomAsync(); + // await room.LeaveAsync(); + // await room.ForgetAsync(); + // return room; + // }).ToList(); + // await Task.WhenAll(disbandRoomTasks); // foreach (var room in rooms) { // // await room.DisbandRoomAsync(); @@ -45,28 +45,28 @@ public static class HomeserverAbstraction { } public static async Task GetRandomHomeserver() { - var rhs = new RemoteHomeServer("https://matrixunittests.rory.gay"); + var rhs = await RemoteHomeServer.Create("https://matrixunittests.rory.gay"); LoginResponse reg = await rhs.RegisterAsync(Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), "Unit tests!"); var hs = await reg.GetAuthenticatedHomeserver("https://matrixunittests.rory.gay"); - var rooms = await hs.GetJoinedRooms(); - - var disbandRoomTasks = rooms.Select(async room => { - // await room.DisbandRoomAsync(); - await room.LeaveAsync(); - await room.ForgetAsync(); - return room; - }).ToList(); - await Task.WhenAll(disbandRoomTasks); + // var rooms = await hs.GetJoinedRooms(); + // + // var disbandRoomTasks = rooms.Select(async room => { + // // await room.DisbandRoomAsync(); + // await room.LeaveAsync(); + // await room.ForgetAsync(); + // return room; + // }).ToList(); + // await Task.WhenAll(disbandRoomTasks); return hs; } public static async IAsyncEnumerable GetRandomHomeservers(int count = 1) { - var createSpaceTasks = Enumerable + var createRandomUserTasks = Enumerable .Range(0, count) .Select(_ => GetRandomHomeserver()).ToAsyncEnumerable(); - await foreach (var hs in createSpaceTasks) { + await foreach (var hs in createRandomUserTasks) { yield return hs; } } diff --git a/Tests/LibMatrix.Tests/Abstractions/RoomAbstraction.cs b/Tests/LibMatrix.Tests/Abstractions/RoomAbstraction.cs index 44c35da..76b8c8c 100644 --- a/Tests/LibMatrix.Tests/Abstractions/RoomAbstraction.cs +++ b/Tests/LibMatrix.Tests/Abstractions/RoomAbstraction.cs @@ -8,11 +8,43 @@ namespace LibMatrix.Tests.Abstractions; public static class RoomAbstraction { public static async Task GetTestRoom(AuthenticatedHomeserverGeneric hs) { - var testRoom = await hs.CreateRoom(new CreateRoomRequest() { + var crq = new CreateRoomRequest() { Name = "LibMatrix Test Room", // Visibility = CreateRoomVisibility.Public, RoomAliasName = Guid.NewGuid().ToString() + }; + crq.InitialState ??= new(); + crq.InitialState.Add(new StateEvent() { + Type = "m.room.topic", + StateKey = "", + TypedContent = new RoomTopicEventContent() { + Topic = "LibMatrix Test Room " + DateTime.Now.ToString("O") + } + }); + crq.InitialState.Add(new StateEvent() { + Type = "m.room.name", + StateKey = "", + TypedContent = new RoomNameEventContent() { + Name = "LibMatrix Test Room " + DateTime.Now.ToString("O") + } + }); + crq.InitialState.Add(new StateEvent() { + Type = "m.room.avatar", + StateKey = "", + TypedContent = new RoomAvatarEventContent() { + Url = "mxc://conduit.rory.gay/r9KiT0f9eQbv8pv4RxwBZFuzhfKjGWHx" + } + }); + crq.InitialState.Add(new StateEvent() { + Type = "m.room.aliases", + StateKey = "", + TypedContent = new RoomAliasEventContent() { + Aliases = Enumerable + .Range(0, 100) + .Select(_ => $"#{Guid.NewGuid()}:matrixunittests.rory.gay").ToList() + } }); + var testRoom = await hs.CreateRoom(crq); await testRoom.SendStateEventAsync("gay.rory.libmatrix.unit_test_room", new()); diff --git a/Tests/LibMatrix.Tests/Tests/RoomEventTests.cs b/Tests/LibMatrix.Tests/Tests/RoomEventTests.cs new file mode 100644 index 0000000..6828087 --- /dev/null +++ b/Tests/LibMatrix.Tests/Tests/RoomEventTests.cs @@ -0,0 +1,160 @@ +using System.Text; +using ArcaneLibs.Extensions; +using LibMatrix.EventTypes.Spec.State; +using LibMatrix.Homeservers; +using LibMatrix.Services; +using LibMatrix.Tests.Abstractions; +using LibMatrix.Tests.Fixtures; +using Xunit.Abstractions; +using Xunit.Microsoft.DependencyInjection.Abstracts; + +namespace LibMatrix.Tests.Tests; + +public class RoomEventTests : TestBed { + private readonly TestFixture _fixture; + private readonly HomeserverResolverService _resolver; + private readonly Config _config; + private readonly HomeserverProviderService _provider; + + public RoomEventTests(ITestOutputHelper testOutputHelper, TestFixture fixture) : base(testOutputHelper, fixture) { + _fixture = fixture; + _resolver = _fixture.GetService(_testOutputHelper) ?? throw new InvalidOperationException($"Failed to get {nameof(HomeserverResolverService)}"); + _config = _fixture.GetService(_testOutputHelper) ?? throw new InvalidOperationException($"Failed to get {nameof(Config)}"); + _provider = _fixture.GetService(_testOutputHelper) ?? throw new InvalidOperationException($"Failed to get {nameof(HomeserverProviderService)}"); + } + + private async Task GetHomeserver() { + return await HomeserverAbstraction.GetHomeserver(); + } + + [Fact] + public async Task GetNameAsync() { + var hs = await HomeserverAbstraction.GetHomeserver(); + var room = await RoomAbstraction.GetTestRoom(hs); + Assert.NotNull(room); + var name = await room.GetNameAsync(); + Assert.NotNull(name); + Assert.NotEmpty(name); + } + + [SkippableFact(typeof(MatrixException))] + public async Task GetTopicAsync() { + var hs = await HomeserverAbstraction.GetHomeserver(); + var room = await RoomAbstraction.GetTestRoom(hs); + Assert.NotNull(room); + var topic = await room.GetTopicAsync(); + Assert.NotNull(topic); + Assert.NotNull(topic.Topic); + Assert.NotEmpty(topic.Topic); + } + + [SkippableFact(typeof(MatrixException))] + public async Task GetAliasesAsync() { + var hs = await HomeserverAbstraction.GetHomeserver(); + var room = await RoomAbstraction.GetTestRoom(hs); + Assert.NotNull(room); + var aliases = await room.GetAliasesAsync(); + Assert.NotNull(aliases); + Assert.NotEmpty(aliases); + Assert.All(aliases, Assert.NotNull); + } + + [SkippableFact(typeof(MatrixException))] + public async Task GetCanonicalAliasAsync() { + var hs = await HomeserverAbstraction.GetHomeserver(); + var room = await RoomAbstraction.GetTestRoom(hs); + Assert.NotNull(room); + var alias = await room.GetCanonicalAliasAsync(); + Assert.NotNull(alias); + Assert.NotNull(alias.Alias); + Assert.NotEmpty(alias.Alias); + } + + [SkippableFact(typeof(MatrixException))] + public async Task GetAvatarUrlAsync() { + var hs = await HomeserverAbstraction.GetHomeserver(); + var room = await RoomAbstraction.GetTestRoom(hs); + Assert.NotNull(room); + var url = await room.GetAvatarUrlAsync(); + Assert.NotNull(url); + Assert.NotNull(url.Url); + Assert.NotEmpty(url.Url); + } + + [Fact] + public async Task GetJoinRuleAsync() { + var hs = await HomeserverAbstraction.GetHomeserver(); + var room = await RoomAbstraction.GetTestRoom(hs); + Assert.NotNull(room); + var rule = await room.GetJoinRuleAsync(); + Assert.NotNull(rule); + Assert.NotNull(rule.JoinRule); + Assert.NotEmpty(rule.JoinRule); + } + + [Fact] + public async Task GetHistoryVisibilityAsync() { + var hs = await HomeserverAbstraction.GetHomeserver(); + var room = await RoomAbstraction.GetTestRoom(hs); + Assert.NotNull(room); + var visibility = await room.GetHistoryVisibilityAsync(); + Assert.NotNull(visibility); + Assert.NotNull(visibility.HistoryVisibility); + Assert.NotEmpty(visibility.HistoryVisibility); + } + + [Fact] + public async Task GetGuestAccessAsync() { + var hs = await HomeserverAbstraction.GetHomeserver(); + var room = await RoomAbstraction.GetTestRoom(hs); + Assert.NotNull(room); + try { + var access = await room.GetGuestAccessAsync(); + Assert.NotNull(access); + Assert.NotNull(access.GuestAccess); + Assert.NotEmpty(access.GuestAccess); + } + catch (Exception e) { + if (e is not MatrixException exception) throw; + Assert.Equal("M_NOT_FOUND", exception.ErrorCode); + } + } + + [Fact] + public async Task GetCreateEventAsync() { + var hs = await HomeserverAbstraction.GetHomeserver(); + var room = await RoomAbstraction.GetTestRoom(hs); + Assert.NotNull(room); + var create = await room.GetCreateEventAsync(); + Assert.NotNull(create); + Assert.NotNull(create.Creator); + Assert.NotEmpty(create.RoomVersion!); + } + + [Fact] + public async Task GetRoomType() { + var hs = await HomeserverAbstraction.GetHomeserver(); + var room = await RoomAbstraction.GetTestRoom(hs); + Assert.NotNull(room); + await room.GetRoomType(); + } + + [Fact] + public async Task GetPowerLevelsAsync() { + var hs = await HomeserverAbstraction.GetHomeserver(); + var room = await RoomAbstraction.GetTestRoom(hs); + Assert.NotNull(room); + var power = await room.GetPowerLevelsAsync(); + Assert.NotNull(power); + Assert.NotNull(power.Ban); + Assert.NotNull(power.Kick); + Assert.NotNull(power.Invite); + Assert.NotNull(power.Redact); + Assert.NotNull(power.StateDefault); + Assert.NotNull(power.EventsDefault); + Assert.NotNull(power.UsersDefault); + Assert.NotNull(power.Users); + // Assert.NotNull(power.Events); + } + +} diff --git a/Tests/LibMatrix.Tests/Tests/RoomTests.cs b/Tests/LibMatrix.Tests/Tests/RoomTests.cs index 17c219d..72a2775 100644 --- a/Tests/LibMatrix.Tests/Tests/RoomTests.cs +++ b/Tests/LibMatrix.Tests/Tests/RoomTests.cs @@ -31,38 +31,18 @@ public class RoomTests : TestBed { public async Task GetJoinedRoomsAsync() { var hs = await HomeserverAbstraction.GetHomeserver(); //make 100 rooms - var createRoomTasks = Enumerable.Range(0, 100).Select(_ => RoomAbstraction.GetTestRoom(hs)).ToList(); + var createRoomTasks = Enumerable.Range(0, 10).Select(_ => RoomAbstraction.GetTestRoom(hs)).ToList(); await Task.WhenAll(createRoomTasks); var rooms = await hs.GetJoinedRooms(); Assert.NotNull(rooms); Assert.NotEmpty(rooms); Assert.All(rooms, Assert.NotNull); - Assert.Equal(100, rooms.Count); + Assert.True(rooms.Count >= 10, "Not enough rooms were found"); await hs.Logout(); } - [Fact] - public async Task GetNameAsync() { - var hs = await HomeserverAbstraction.GetHomeserver(); - var room = await RoomAbstraction.GetTestRoom(hs); - Assert.NotNull(room); - var name = await room.GetNameAsync(); - Assert.NotNull(name); - Assert.NotEmpty(name); - } - - [SkippableFact(typeof(MatrixException))] - public async Task GetTopicAsync() { - var hs = await HomeserverAbstraction.GetHomeserver(); - var room = await RoomAbstraction.GetTestRoom(hs); - Assert.NotNull(room); - var topic = await room.GetTopicAsync(); - Assert.NotNull(topic); - Assert.NotNull(topic.Topic); - Assert.NotEmpty(topic.Topic); - } [Fact] public async Task GetMembersAsync() { @@ -106,115 +86,6 @@ public class RoomTests : TestBed { Assert.NotEmpty(id.RoomId); } - [SkippableFact(typeof(MatrixException))] - public async Task GetAliasesAsync() { - var hs = await HomeserverAbstraction.GetHomeserver(); - var room = await RoomAbstraction.GetTestRoom(hs); - Assert.NotNull(room); - var aliases = await room.GetAliasesAsync(); - Assert.NotNull(aliases); - Assert.NotEmpty(aliases); - Assert.All(aliases, Assert.NotNull); - } - - [SkippableFact(typeof(MatrixException))] - public async Task GetCanonicalAliasAsync() { - var hs = await HomeserverAbstraction.GetHomeserver(); - var room = await RoomAbstraction.GetTestRoom(hs); - Assert.NotNull(room); - var alias = await room.GetCanonicalAliasAsync(); - Assert.NotNull(alias); - Assert.NotNull(alias.Alias); - Assert.NotEmpty(alias.Alias); - } - - [SkippableFact(typeof(MatrixException))] - public async Task GetAvatarUrlAsync() { - var hs = await HomeserverAbstraction.GetHomeserver(); - var room = await RoomAbstraction.GetTestRoom(hs); - Assert.NotNull(room); - var url = await room.GetAvatarUrlAsync(); - Assert.NotNull(url); - Assert.NotNull(url.Url); - Assert.NotEmpty(url.Url); - } - - [Fact] - public async Task GetJoinRuleAsync() { - var hs = await HomeserverAbstraction.GetHomeserver(); - var room = await RoomAbstraction.GetTestRoom(hs); - Assert.NotNull(room); - var rule = await room.GetJoinRuleAsync(); - Assert.NotNull(rule); - Assert.NotNull(rule.JoinRule); - Assert.NotEmpty(rule.JoinRule); - } - - [Fact] - public async Task GetHistoryVisibilityAsync() { - var hs = await HomeserverAbstraction.GetHomeserver(); - var room = await RoomAbstraction.GetTestRoom(hs); - Assert.NotNull(room); - var visibility = await room.GetHistoryVisibilityAsync(); - Assert.NotNull(visibility); - Assert.NotNull(visibility.HistoryVisibility); - Assert.NotEmpty(visibility.HistoryVisibility); - } - - [Fact] - public async Task GetGuestAccessAsync() { - var hs = await HomeserverAbstraction.GetHomeserver(); - var room = await RoomAbstraction.GetTestRoom(hs); - Assert.NotNull(room); - try { - var access = await room.GetGuestAccessAsync(); - Assert.NotNull(access); - Assert.NotNull(access.GuestAccess); - Assert.NotEmpty(access.GuestAccess); - } - catch (Exception e) { - if (e is not MatrixException exception) throw; - Assert.Equal("M_NOT_FOUND", exception.ErrorCode); - } - } - - [Fact] - public async Task GetCreateEventAsync() { - var hs = await HomeserverAbstraction.GetHomeserver(); - var room = await RoomAbstraction.GetTestRoom(hs); - Assert.NotNull(room); - var create = await room.GetCreateEventAsync(); - Assert.NotNull(create); - Assert.NotNull(create.Creator); - Assert.NotEmpty(create.RoomVersion!); - } - - [Fact] - public async Task GetRoomType() { - var hs = await HomeserverAbstraction.GetHomeserver(); - var room = await RoomAbstraction.GetTestRoom(hs); - Assert.NotNull(room); - await room.GetRoomType(); - } - - [Fact] - public async Task GetPowerLevelsAsync() { - var hs = await HomeserverAbstraction.GetHomeserver(); - var room = await RoomAbstraction.GetTestRoom(hs); - Assert.NotNull(room); - var power = await room.GetPowerLevelsAsync(); - Assert.NotNull(power); - Assert.NotNull(power.Ban); - Assert.NotNull(power.Kick); - Assert.NotNull(power.Invite); - Assert.NotNull(power.Redact); - Assert.NotNull(power.StateDefault); - Assert.NotNull(power.EventsDefault); - Assert.NotNull(power.UsersDefault); - Assert.NotNull(power.Users); - // Assert.NotNull(power.Events); - } - [Fact] public async Task ForgetAsync() { var hs = await HomeserverAbstraction.GetHomeserver(); @@ -361,17 +232,21 @@ public class RoomTests : TestBed { [Fact] public async Task InviteAndJoinAsync() { - var otherUsers = HomeserverAbstraction.GetRandomHomeservers(7); var hs = await HomeserverAbstraction.GetHomeserver(); var room = await RoomAbstraction.GetTestRoom(hs); + var otherUsers = HomeserverAbstraction.GetRandomHomeservers(15); Assert.NotNull(room); // var expectedCount = 1; + var tasks = new List(); await foreach(var otherUser in otherUsers) { - await room.InviteUserAsync(otherUser.UserId); - await otherUser.GetRoom(room.RoomId).JoinAsync(); + tasks.Add(Task.Run(async () => { + await room.InviteUserAsync(otherUser.UserId); + await otherUser.GetRoom(room.RoomId).JoinAsync(); + })); } + await Task.WhenAll(tasks); var states = room.GetMembersAsync(false); var count = 0; @@ -379,6 +254,6 @@ public class RoomTests : TestBed { count++; } // Assert.Equal(++expectedCount, count); - Assert.Equal(8, count); + Assert.Equal(16, count); } } diff --git a/Tests/LibMatrix.Tests/Tests/TestCleanup.cs b/Tests/LibMatrix.Tests/Tests/TestCleanup.cs new file mode 100644 index 0000000..e93de3d --- /dev/null +++ b/Tests/LibMatrix.Tests/Tests/TestCleanup.cs @@ -0,0 +1,76 @@ +using System.Diagnostics; +using ArcaneLibs.Extensions; +using LibMatrix.Helpers; +using LibMatrix.Services; +using LibMatrix.Tests.Abstractions; +using LibMatrix.Tests.DataTests; +using LibMatrix.Tests.Fixtures; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; +using Xunit.Microsoft.DependencyInjection.Abstracts; + +namespace LibMatrix.Tests.Tests; + +public class TestCleanup : TestBed { + private readonly TestFixture _fixture; + private readonly HomeserverResolverService _resolver; + private readonly Config _config; + private readonly HomeserverProviderService _provider; + private readonly ILogger _logger; + + public TestCleanup(ITestOutputHelper testOutputHelper, TestFixture fixture) : base(testOutputHelper, fixture) { + _fixture = fixture; + _resolver = _fixture.GetService(_testOutputHelper) ?? throw new InvalidOperationException($"Failed to get {nameof(HomeserverResolverService)}"); + _config = _fixture.GetService(_testOutputHelper) ?? throw new InvalidOperationException($"Failed to get {nameof(Config)}"); + _provider = _fixture.GetService(_testOutputHelper) ?? throw new InvalidOperationException($"Failed to get {nameof(HomeserverProviderService)}"); + _logger = _fixture.GetService>(_testOutputHelper) ?? throw new InvalidOperationException($"Failed to get {nameof(ILogger)}"); + } + + [Fact] + public async Task Cleanup() { + Assert.False(string.IsNullOrWhiteSpace(_config.TestHomeserver), $"{nameof(_config.TestHomeserver)} must be set in appsettings!"); + Assert.False(string.IsNullOrWhiteSpace(_config.TestUsername), $"{nameof(_config.TestUsername)} must be set in appsettings!"); + Assert.False(string.IsNullOrWhiteSpace(_config.TestPassword), $"{nameof(_config.TestPassword)} must be set in appsettings!"); + + var hs = await HomeserverAbstraction.GetHomeserver(); + Assert.NotNull(hs); + + var syncHelper = new SyncHelper(hs, _logger) { + Timeout = 3000 + }; + _testOutputHelper.WriteLine("Starting sync loop"); + var cancellationTokenSource = new CancellationTokenSource(); + var sw = Stopwatch.StartNew(); + syncHelper.SyncReceivedHandlers.Add(async response => { + if (sw.ElapsedMilliseconds >= 3000) { + _testOutputHelper.WriteLine("Cancelling sync loop"); + + var tasks = (await hs.GetJoinedRooms()).Select(async room => { + _logger.LogInformation("Leaving room: {}", room.RoomId); + await room.LeaveAsync(); + await room.ForgetAsync(); + return room; + }).ToList(); + await Task.WhenAll(tasks); + + cancellationTokenSource.Cancel(); + } + + sw.Restart(); + if (response.Rooms?.Leave is { Count: > 0 }) { + // foreach (var room in response.Rooms.Leave) { + // await hs.GetRoom(room.Key).ForgetAsync(); + // } + var tasks = response.Rooms.Leave.Select(async room => { + await hs.GetRoom(room.Key).ForgetAsync(); + return room; + }).ToList(); + await Task.WhenAll(tasks); + } + }); + await syncHelper.RunSyncLoopAsync(cancellationToken: cancellationTokenSource.Token); + + Assert.NotNull(hs); + await hs.Logout(); + } +} diff --git a/Utilities/LibMatrix.Utilities.Bot/Services/CommandListenerHostedService.cs b/Utilities/LibMatrix.Utilities.Bot/Services/CommandListenerHostedService.cs index 910db0a..0dcf3e3 100644 --- a/Utilities/LibMatrix.Utilities.Bot/Services/CommandListenerHostedService.cs +++ b/Utilities/LibMatrix.Utilities.Bot/Services/CommandListenerHostedService.cs @@ -37,7 +37,8 @@ public class CommandListenerHostedService : IHostedService { private async Task? Run(CancellationToken cancellationToken) { _logger.LogInformation("Starting command listener!"); - _hs.SyncHelper.TimelineEventHandlers.Add(async @event => { + var syncHelper = new SyncHelper(_hs); + syncHelper.TimelineEventHandlers.Add(async @event => { try { var room = _hs.GetRoom(@event.RoomId); // _logger.LogInformation(eventResponse.ToJson(indent: false)); @@ -80,7 +81,7 @@ public class CommandListenerHostedService : IHostedService { _logger.LogError(e, "Error in command listener!"); } }); - await _hs.SyncHelper.RunSyncLoop(cancellationToken: cancellationToken); + await new SyncHelper(_hs).RunSyncLoopAsync(cancellationToken: cancellationToken); } /// Triggered when the application host is performing a graceful shutdown. -- cgit 1.4.1