about summary refs log tree commit diff
path: root/MatrixUtils.Web/Pages/Tools/Moderation
diff options
context:
space:
mode:
Diffstat (limited to 'MatrixUtils.Web/Pages/Tools/Moderation')
-rw-r--r--MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirProtectedRoomsEditor.razor138
-rw-r--r--MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirProtectionsEditor.razor (renamed from MatrixUtils.Web/Pages/Tools/Moderation/DraupnirProtectedRoomsEditor.razor)47
-rw-r--r--MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirWatchedListsEditor.razor139
-rw-r--r--MatrixUtils.Web/Pages/Tools/Moderation/FindUsersByRegex.razor192
-rw-r--r--MatrixUtils.Web/Pages/Tools/Moderation/InviteCounter.razor28
-rw-r--r--MatrixUtils.Web/Pages/Tools/Moderation/MassCMEBan.razor53
-rw-r--r--MatrixUtils.Web/Pages/Tools/Moderation/MembershipHistory.razor694
-rw-r--r--MatrixUtils.Web/Pages/Tools/Moderation/RoomIntersections.razor4
-rw-r--r--MatrixUtils.Web/Pages/Tools/Moderation/UserTrace.razor155
9 files changed, 1138 insertions, 312 deletions
diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirProtectedRoomsEditor.razor b/MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirProtectedRoomsEditor.razor
new file mode 100644

index 0000000..b0d5a65 --- /dev/null +++ b/MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirProtectedRoomsEditor.razor
@@ -0,0 +1,138 @@ +@page "/Moderation/DraupnirProtectedRoomsEditor" +@page "/Tools/Moderation/DraupnirProtectedRoomsEditor" +@page "/Tools/Moderation/Draupnir/ProtectedRoomsEditor" +@using LibMatrix +@using LibMatrix.EventTypes.Interop.Draupnir +@using LibMatrix.EventTypes.Spec.State.RoomInfo +@using LibMatrix.RoomTypes +<h3>Edit Draupnir protected rooms</h3> +<hr/> +<p><b>Note:</b> You will need to restart Draupnir after applying changes!</p> +<p>Minor note: This <i>should</i> also work with Mjolnir, but this hasn't been tested, and as such functionality cannot be guaranteed.</p> + +@if (data is not null) { + <div class="row"> + <div class="col-12"> + <details> + <summary>Currently protected room IDs</summary> + <ul> + @foreach (var room in data.Rooms) { + <li>@room</li> + } + </ul> + </details> + <hr/> + <h4>Tickyboxes</h4> + <table class="table"> + <thead> + <tr> + <th></th> @* Checkbox column *@ + <th>Kick?</th> @* PL > kick *@ + <th>Ban?</th> @* PL > ban *@ + <th>ACL?</th> @* PL > m.room.server_acls event *@ + <th>Room ID</th> + <th>Room name</th> + </tr> + </thead> + <tbody> + @foreach (var room in Rooms.OrderBy(x => x.RoomName)) { + <tr> + <td> + <input type="checkbox" @bind="room.IsProtected"/> + </td> + <td>@(room.PowerLevels.Kick <= room.PowerLevels.GetUserPowerLevel(hs.UserId) ? "X" : "")</td> + <td>@(room.PowerLevels.Ban <= room.PowerLevels.GetUserPowerLevel(hs.UserId) ? "X" : "")</td> + <td>@(room.PowerLevels.UserHasStatePermission(hs.UserId, RoomServerAclEventContent.EventId) ? "X" : "")</td> + <td>@room.Room.RoomId</td> + <td>@room.RoomName</td> + </tr> + } + </tbody> + </table> + </div> + </div> +} +<br/> +<LinkButton OnClick="@Apply">Apply</LinkButton> + + +@code { + private DraupnirProtectedRoomsData data { get; set; } = new(); + private List<EditorRoomInfo> Rooms { get; set; } = new(); + private AuthenticatedHomeserverGeneric hs { get; set; } + + protected override async Task OnInitializedAsync() { + hs = await sessionStore.GetCurrentHomeserver(navigateOnFailure: true); + if (hs is null) return; + data = await hs.GetAccountDataAsync<DraupnirProtectedRoomsData>(DraupnirProtectedRoomsData.EventId); + StateHasChanged(); + var tasks = (await hs.GetJoinedRooms()).Select(async room => { + var plTask = room.GetPowerLevelsAsync(); + var roomNameTask = room.GetNameOrFallbackAsync(); + var EditorRoomInfo = new EditorRoomInfo { + Room = room, + IsProtected = data.Rooms.Contains(room.RoomId), + RoomName = await roomNameTask, + PowerLevels = await plTask + }; + + Rooms.Add(EditorRoomInfo); + StateHasChanged(); + return Task.CompletedTask; + }).ToList(); + await Task.WhenAll(tasks); + await Task.Delay(500); + + foreach (var protectedRoomId in data.Rooms) { + if (Rooms.Any(x => x.Room.RoomId == protectedRoomId)) continue; + var room = hs.GetRoom(protectedRoomId); + var editorRoomInfo = new EditorRoomInfo { + Room = room, + IsProtected = true + }; + + try { + var pl = await room.GetPowerLevelsAsync(); + editorRoomInfo.PowerLevels = pl; + } + catch (MatrixException e) { + Console.WriteLine($"Failed to get power levels for {room.RoomId}: {e}"); + } + + try { + editorRoomInfo.RoomName = await room.GetNameOrFallbackAsync(); + } + catch (MatrixException e) { + Console.WriteLine($"Failed to get name for {room.RoomId}: {e}"); + } + + try { + var membership = await room.GetStateEventOrNullAsync(hs.UserId); + if (membership is not null) { + editorRoomInfo.RoomName = $"(!! {membership.ContentAs<RoomMemberEventContent>()?.Membership ?? "null"} !!) {editorRoomInfo.RoomName}"; + } + } + catch (MatrixException e) { + Console.WriteLine($"Failed to get membership for {room.RoomId}: {e}"); + } + + Rooms.Add(editorRoomInfo); + } + + StateHasChanged(); + } + + private class EditorRoomInfo { + public GenericRoom Room { get; set; } + public bool IsProtected { get; set; } + public string RoomName { get; set; } + public RoomPowerLevelEventContent PowerLevels { get; set; } + } + + private async Task Apply() { + Console.WriteLine(string.Join('\n', Rooms.Where(x => x.IsProtected).Select(x => x.Room.RoomId))); + data.Rooms = Rooms.Where(x => x.IsProtected).Select(x => x.Room.RoomId).ToList(); + await hs.SetAccountDataAsync("org.matrix.mjolnir.protected_rooms", data); + } + +} \ No newline at end of file diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/DraupnirProtectedRoomsEditor.razor b/MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirProtectionsEditor.razor
index 805bd40..ea39c9a 100644 --- a/MatrixUtils.Web/Pages/Tools/Moderation/DraupnirProtectedRoomsEditor.razor +++ b/MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirProtectionsEditor.razor
@@ -1,7 +1,7 @@ -@page "/Moderation/DraupnirProtectedRoomsEditor" -@page "/Tools/Moderation/DraupnirProtectedRoomsEditor" +@page "/Tools/Moderation/Draupnir/ProtectionsEditor" @using System.Text.Json.Serialization -@using LibMatrix.EventTypes.Spec.State +@using LibMatrix +@using LibMatrix.EventTypes.Spec.State.RoomInfo @using LibMatrix.RoomTypes <h3>Edit Draupnir protected rooms</h3> <hr/> @@ -38,7 +38,7 @@ </td> <td>@(room.PowerLevels.Kick <= room.PowerLevels.GetUserPowerLevel(hs.UserId) ? "X" : "")</td> <td>@(room.PowerLevels.Ban <= room.PowerLevels.GetUserPowerLevel(hs.UserId) ? "X" : "")</td> - <td>@(room.PowerLevels.UserHasStatePermission(hs.UserId, RoomServerACLEventContent.EventId) ? "X" : "")</td> + <td>@(room.PowerLevels.UserHasStatePermission(hs.UserId, RoomServerAclEventContent.EventId) ? "X" : "")</td> <td>@room.Room.RoomId</td> <td>@room.RoomName</td> </tr> @@ -58,7 +58,7 @@ private AuthenticatedHomeserverGeneric hs { get; set; } protected override async Task OnInitializedAsync() { - hs = await RMUStorage.GetCurrentSessionOrNavigate(); + hs = await sessionStore.GetCurrentHomeserver(navigateOnFailure: true); if (hs is null) return; data = await hs.GetAccountDataAsync<DraupnirProtectedRoomsData>("org.matrix.mjolnir.protected_rooms"); StateHasChanged(); @@ -78,6 +78,43 @@ }).ToList(); await Task.WhenAll(tasks); await Task.Delay(500); + + foreach (var protectedRoomId in data.Rooms) { + if (Rooms.Any(x => x.Room.RoomId == protectedRoomId)) continue; + var room = hs.GetRoom(protectedRoomId); + var editorRoomInfo = new EditorRoomInfo { + Room = room, + IsProtected = true + }; + + try { + var pl = await room.GetPowerLevelsAsync(); + editorRoomInfo.PowerLevels = pl; + } + catch (MatrixException e) { + Console.WriteLine($"Failed to get power levels for {room.RoomId}: {e}"); + } + + try { + editorRoomInfo.RoomName = await room.GetNameOrFallbackAsync(); + } + catch (MatrixException e) { + Console.WriteLine($"Failed to get name for {room.RoomId}: {e}"); + } + + try { + var membership = await room.GetStateEventOrNullAsync(hs.UserId); + if (membership is not null) { + editorRoomInfo.RoomName = $"(!! {membership.ContentAs<RoomMemberEventContent>()?.Membership ?? "null"} !!) {editorRoomInfo.RoomName}"; + } + } + catch (MatrixException e) { + Console.WriteLine($"Failed to get membership for {room.RoomId}: {e}"); + } + + Rooms.Add(editorRoomInfo); + } + StateHasChanged(); } diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirWatchedListsEditor.razor b/MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirWatchedListsEditor.razor new file mode 100644
index 0000000..9e70687 --- /dev/null +++ b/MatrixUtils.Web/Pages/Tools/Moderation/Draupnir/DraupnirWatchedListsEditor.razor
@@ -0,0 +1,139 @@ +@page "/Tools/Moderation/Draupnir/WatchedListsEditor" +@using System.Text.Json.Serialization +@using LibMatrix +@using LibMatrix.EventTypes.Spec.State.RoomInfo +@using LibMatrix.RoomTypes +<h3>Edit Draupnir protected rooms</h3> +<hr/> +<p><b>Note:</b> You will need to restart Draupnir after applying changes!</p> +<p>Minor note: This <i>should</i> also work with Mjolnir, but this hasn't been tested, and as such functionality cannot be guaranteed.</p> + +@if (data is not null) { + <div class="row"> + <div class="col-12"> + <h4>Current rooms</h4> + <ul> + @foreach (var room in data.Rooms) { + <li>@room</li> + } + </ul> + <hr/> + <h4>Tickyboxes</h4> + <table class="table"> + <thead> + <tr> + <th></th> @* Checkbox column *@ + <th>Kick?</th> @* PL > kick *@ + <th>Ban?</th> @* PL > ban *@ + <th>ACL?</th> @* PL > m.room.server_acls event *@ + <th>Room ID</th> + <th>Room name</th> + </tr> + </thead> + <tbody> + @foreach (var room in Rooms.OrderBy(x => x.RoomName)) { + <tr> + <td> + <input type="checkbox" @bind="room.IsProtected"/> + </td> + <td>@(room.PowerLevels.Kick <= room.PowerLevels.GetUserPowerLevel(hs.UserId) ? "X" : "")</td> + <td>@(room.PowerLevels.Ban <= room.PowerLevels.GetUserPowerLevel(hs.UserId) ? "X" : "")</td> + <td>@(room.PowerLevels.UserHasStatePermission(hs.UserId, RoomServerAclEventContent.EventId) ? "X" : "")</td> + <td>@room.Room.RoomId</td> + <td>@room.RoomName</td> + </tr> + } + </tbody> + </table> + </div> + </div> +} +<br/> +<LinkButton OnClick="@Apply">Apply</LinkButton> + + +@code { + private DraupnirProtectedRoomsData data { get; set; } = new(); + private List<EditorRoomInfo> Rooms { get; set; } = new(); + private AuthenticatedHomeserverGeneric hs { get; set; } + + protected override async Task OnInitializedAsync() { + hs = await sessionStore.GetCurrentHomeserver(navigateOnFailure: true); + if (hs is null) return; + data = await hs.GetAccountDataAsync<DraupnirProtectedRoomsData>("org.matrix.mjolnir.protected_rooms"); + StateHasChanged(); + var tasks = (await hs.GetJoinedRooms()).Select(async room => { + var plTask = room.GetPowerLevelsAsync(); + var roomNameTask = room.GetNameOrFallbackAsync(); + var EditorRoomInfo = new EditorRoomInfo { + Room = room, + IsProtected = data.Rooms.Contains(room.RoomId), + RoomName = await roomNameTask, + PowerLevels = await plTask + }; + + Rooms.Add(EditorRoomInfo); + StateHasChanged(); + return Task.CompletedTask; + }).ToList(); + await Task.WhenAll(tasks); + await Task.Delay(500); + + foreach (var protectedRoomId in data.Rooms) { + if (Rooms.Any(x => x.Room.RoomId == protectedRoomId)) continue; + var room = hs.GetRoom(protectedRoomId); + var editorRoomInfo = new EditorRoomInfo { + Room = room, + IsProtected = true + }; + + try { + var pl = await room.GetPowerLevelsAsync(); + editorRoomInfo.PowerLevels = pl; + } + catch (MatrixException e) { + Console.WriteLine($"Failed to get power levels for {room.RoomId}: {e}"); + } + + try { + editorRoomInfo.RoomName = await room.GetNameOrFallbackAsync(); + } + catch (MatrixException e) { + Console.WriteLine($"Failed to get name for {room.RoomId}: {e}"); + } + + try { + var membership = await room.GetStateEventOrNullAsync(hs.UserId); + if (membership is not null) { + editorRoomInfo.RoomName = $"(!! {membership.ContentAs<RoomMemberEventContent>()?.Membership ?? "null"} !!) {editorRoomInfo.RoomName}"; + } + } + catch (MatrixException e) { + Console.WriteLine($"Failed to get membership for {room.RoomId}: {e}"); + } + + Rooms.Add(editorRoomInfo); + } + + StateHasChanged(); + } + + private class DraupnirProtectedRoomsData { + [JsonPropertyName("rooms")] + public List<string> Rooms { get; set; } = new(); + } + + private class EditorRoomInfo { + public GenericRoom Room { get; set; } + public bool IsProtected { get; set; } + public string RoomName { get; set; } + public RoomPowerLevelEventContent PowerLevels { get; set; } + } + + private async Task Apply() { + Console.WriteLine(string.Join('\n', Rooms.Where(x => x.IsProtected).Select(x => x.Room.RoomId))); + data.Rooms = Rooms.Where(x => x.IsProtected).Select(x => x.Room.RoomId).ToList(); + await hs.SetAccountDataAsync("org.matrix.mjolnir.protected_rooms", data); + } + +} \ No newline at end of file diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/FindUsersByRegex.razor b/MatrixUtils.Web/Pages/Tools/Moderation/FindUsersByRegex.razor new file mode 100644
index 0000000..b62cf57 --- /dev/null +++ b/MatrixUtils.Web/Pages/Tools/Moderation/FindUsersByRegex.razor
@@ -0,0 +1,192 @@ +@page "/Tools/Moderation/FindUsersByRegex" +@using System.Collections.Frozen +@using ArcaneLibs.Extensions +@using LibMatrix.RoomTypes +@using System.Collections.ObjectModel +@using System.Text.RegularExpressions +@using LibMatrix +@using LibMatrix.EventTypes.Spec.State.RoomInfo +@using LibMatrix.Filters +@using LibMatrix.Helpers +<h3>Find users by regex</h3> +<hr/> + +<p>Users (regex): </p> +<InputTextArea @bind-Value="@UserIdString"></InputTextArea> + +<LinkButton OnClick="Execute">Execute</LinkButton> +<br/> +<LinkButton OnClick="RemoveKicks">Remove kicks</LinkButton> +<LinkButton OnClick="RemoveBans">Remove bans</LinkButton> +<br/> + + +<details> + <summary>Results</summary> + @foreach (var (userId, events) in matches) { + <h4>@userId</h4> + <ul> + @foreach (var match in events) { + <li> + <ul> + <li>@match.RoomName (<span>@match.Room.RoomId</span>)</li> + <li>Membership: @(match.Event.RawContent.ToJson(indent: false)) (sent by @match.Event.Sender)</li> + </ul> + </li> + } + </ul> + } +</details> + +<br/> +@foreach (var line in log.Reverse()) { + <pre>@line</pre> +} + +@code { + + private ObservableCollection<string> log { get; set; } = new(); + + // List<RoomInfo> rooms { get; set; } = new(); + List<GenericRoom> rooms { get; set; } = []; + Dictionary<string, List<Match>> matches = new(); + + private string UserIdString { + get => string.Join("\n", UserIDs); + set => UserIDs = value.Split("\n").Select(x => x.Trim()).Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); + } + + private List<string> UserIDs { get; set; } = new(); + + private AuthenticatedHomeserverGeneric hs { get; set; } + + protected override async Task OnInitializedAsync() { + log.CollectionChanged += (sender, args) => StateHasChanged(); + log.Add("Authenticating"); + hs = await sessionStore.GetCurrentHomeserver(navigateOnFailure: true); + if (hs is null) return; + + StateHasChanged(); + Console.WriteLine("Rerendered!"); + await base.OnInitializedAsync(); + } + + private async Task<string> Execute() { + log.Add("Constructing sync helper..."); + var sh = new SyncHelper(hs) { + Filter = new SyncFilter() { + AccountData = new(types: []), + Presence = new(types: []), + Room = new() { + AccountData = new(types: []), + Ephemeral = new(types: []), + State = new(types: [RoomMemberEventContent.EventId]), + Timeline = new(types: []), + IncludeLeave = false + }, + } + }; + + log.Add("Starting sync..."); + var res = await sh.SyncAsync(); + + log.Add("Got sync response, parsing..."); + + var roomNames = (await Task.WhenAll((await hs.GetJoinedRooms()).Select(async room => { return (room.RoomId, await room.GetNameOrFallbackAsync()); }).ToList())).ToFrozenDictionary(x => x.Item1, x => x.Item2); + + foreach (var userIdRegex in UserIDs) { + var regex = new Regex(userIdRegex, RegexOptions.Compiled); + log.Add($"Searching for {regex}:"); + foreach (var (roomId, joinedRoom) in res.Rooms.Join) { + log.Add($"- Checking room {roomId}..."); + foreach (var evt in joinedRoom.State.Events) { + if (evt.StateKey is null) continue; + if (evt.Type is not RoomMemberEventContent.EventId) continue; + + if (regex.IsMatch(evt.StateKey)) { + log.Add($" - Found match in {roomId} for {evt.StateKey}"); + if (!matches.ContainsKey(evt.StateKey)) { + matches[evt.StateKey] = new(); + } + + var room = hs.GetRoom(roomId); + matches[evt.StateKey].Add(new Match { + Room = room, + Event = evt, + RoomName = roomNames[roomId] + }); + } + } + } + } + + log.Add("Done!"); + + StateHasChanged(); + + return ""; + } + + public string? ImportFromRoomId { get; set; } + + private async Task DoImportFromRoomId() { + try { + if (ImportFromRoomId is null) return; + var room = rooms.FirstOrDefault(x => x.RoomId == ImportFromRoomId); + UserIdString = string.Join("\n", (await room.GetMembersListAsync()).Select(x => x.StateKey)); + } + catch (Exception e) { + Console.WriteLine(e); + log.Add("Could not fetch members list!\n" + e.ToString()); + } + + StateHasChanged(); + } + + private class Match { + public GenericRoom Room; + public StateEventResponse Event; + public string RoomName { get; set; } + } + + private async IAsyncEnumerable<Match> GetMatches(string userId) { + var results = rooms.Select(async room => { + var state = await room.GetStateEventOrNullAsync(room.RoomId, userId); + if (state is not null) { + return new Match { + Room = room, + Event = state, + RoomName = await room.GetNameOrFallbackAsync() + }; + } + + return null; + }).ToAsyncEnumerable(); + await foreach (var result in results) { + if (result is not null) { + yield return result; + } + } + } + + private Task RemoveKicks() { + foreach (var (userId, matches) in matches) { + matches.RemoveAll(x => x.Event.ContentAs<RoomMemberEventContent>()!.Membership == "leave" && x.Event.Sender != x.Event.StateKey); + } + + matches.RemoveAll((x, y) => y.Count == 0); + StateHasChanged(); + return Task.CompletedTask; + } + + private Task RemoveBans() { + foreach (var (userId, matches) in matches) { + matches.RemoveAll(x => x.Event.ContentAs<RoomMemberEventContent>()!.Membership == "ban" && x.Event.Sender != x.Event.StateKey); + } + + matches.RemoveAll((x, y) => y.Count == 0); + StateHasChanged(); + return Task.CompletedTask; + } + +} \ No newline at end of file diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/InviteCounter.razor b/MatrixUtils.Web/Pages/Tools/Moderation/InviteCounter.razor
index 2123d4d..5c5946f 100644 --- a/MatrixUtils.Web/Pages/Tools/Moderation/InviteCounter.razor +++ b/MatrixUtils.Web/Pages/Tools/Moderation/InviteCounter.razor
@@ -1,6 +1,8 @@ @page "/Tools/Moderation/InviteCounter" @using System.Collections.ObjectModel -@using LibMatrix.EventTypes.Spec.State +@using ArcaneLibs.Extensions +@using LibMatrix.EventTypes.Spec.State.RoomInfo +@using LibMatrix.Filters <h3>Invite counter</h3> <hr/> @@ -13,7 +15,7 @@ <details> <summary>Results</summary> - @foreach (var (userId, events) in invites.OrderByDescending(x=>x.Value).ToList()) { + @foreach (var (userId, events) in invites.OrderByDescending(x => x.Value).ToList()) { <p>@userId: @events</p> } </details> @@ -27,16 +29,15 @@ private ObservableCollection<string> log { get; set; } = new(); private Dictionary<string, int> invites { get; set; } = new(); private AuthenticatedHomeserverGeneric hs { get; set; } - + [Parameter, SupplyParameterFromQuery(Name = "room")] public string roomId { get; set; } - protected override async Task OnInitializedAsync() { log.CollectionChanged += (sender, args) => StateHasChanged(); - hs = await RMUStorage.GetCurrentSessionOrNavigate(); + hs = await sessionStore.GetCurrentHomeserver(navigateOnFailure: true); if (hs is null) return; - + StateHasChanged(); Console.WriteLine("Rerendered!"); await base.OnInitializedAsync(); @@ -44,22 +45,21 @@ private async Task<string> Execute() { var room = hs.GetRoom(roomId); - var events = room.GetManyMessagesAsync(limit: int.MaxValue); + var filter = new SyncFilter.EventFilter() { Types = [RoomMemberEventContent.EventId] }; + var events = room.GetManyMessagesAsync(limit: int.MaxValue, filter: filter.ToJson(ignoreNull: true, indent: false)); await foreach (var resp in events) { var all = resp.State.Concat(resp.Chunk); foreach (var evt in all) { - if(evt.Type != RoomMemberEventContent.EventId) continue; + if (evt.Type != RoomMemberEventContent.EventId) continue; var content = evt.TypedContent as RoomMemberEventContent; - if(content.Membership != "invite") continue; - if(!invites.ContainsKey(evt.Sender)) invites[evt.Sender] = 0; - invites[evt.Sender]++; + if (content?.Membership != "invite") continue; + invites.TryAdd(evt.Sender!, 0); + invites[evt.Sender!]++; } log.Add($"{resp.State.Count} state, {resp.Chunk.Count} timeline"); } - - - + StateHasChanged(); return ""; diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/MassCMEBan.razor b/MatrixUtils.Web/Pages/Tools/Moderation/MassCMEBan.razor
index ea1e5f6..8fdad84 100644 --- a/MatrixUtils.Web/Pages/Tools/Moderation/MassCMEBan.razor +++ b/MatrixUtils.Web/Pages/Tools/Moderation/MassCMEBan.razor
@@ -1,6 +1,7 @@ @page "/Tools/Moderation/MassCMEBan" @using System.Collections.ObjectModel @using LibMatrix.EventTypes.Spec.State.Policy +@using LibMatrix.RoomTypes <h3>User Trace</h3> <hr/> @@ -17,19 +18,19 @@ } @code { + // TODO: Properly implement page to be more useful private ObservableCollection<string> log { get; set; } = new(); private AuthenticatedHomeserverGeneric hs { get; set; } - + [Parameter, SupplyParameterFromQuery(Name = "room")] public string roomId { get; set; } - protected override async Task OnInitializedAsync() { log.CollectionChanged += (sender, args) => StateHasChanged(); - hs = await RMUStorage.GetCurrentSessionOrNavigate(); + hs = await sessionStore.GetCurrentHomeserver(navigateOnFailure: true); if (hs is null) return; - + StateHasChanged(); Console.WriteLine("Rerendered!"); await base.OnInitializedAsync(); @@ -37,33 +38,41 @@ private async Task<string> Execute() { var room = hs.GetRoom("!fTjMjIzNKEsFlUIiru:neko.dev"); - // var room = hs.GetRoom("!yf7OpOiRDXx6zUGpT6:conduit.rory.gay"); - var users = roomId.Split("\n").Select(x => x.Trim()).Where(x=>x.StartsWith('@')).ToList(); - foreach (var user in users) { - var exists = false; - try { - exists = !string.IsNullOrWhiteSpace((await room.GetStateAsync<UserPolicyRuleEventContent>(UserPolicyRuleEventContent.EventId, user.Replace('@', '_'))).Entity); - } catch (Exception e) { - log.Add($"Failed to get {user}"); - } + // var room = hs.GetRoom("!IVSjKMsVbjXsmUTuRR:rory.gay"); + var users = roomId.Split("\n").Select(x => x.Trim()).Where(x => x.StartsWith('@')).ToList(); + var tasks = users.Select(x => ExecuteBan(room, x)).ToList(); + await Task.WhenAll(tasks); + + StateHasChanged(); + + return ""; + } - if (!exists) { + private async Task ExecuteBan(GenericRoom room, string user) { + var exists = false; + try { + exists = !string.IsNullOrWhiteSpace((await room.GetStateAsync<UserPolicyRuleEventContent>(UserPolicyRuleEventContent.EventId, user.Replace('@', '_'))).Entity); + } + catch (Exception e) { + log.Add($"Failed to get {user}"); + } + + if (!exists) { + try { var evt = await room.SendStateEventAsync(UserPolicyRuleEventContent.EventId, user.Replace('@', '_'), new UserPolicyRuleEventContent() { Entity = user, - Reason = "spam (invite)", + Reason = "spam", Recommendation = "m.ban" }); log.Add($"Sent {evt.EventId} to ban {user}"); } - else { - log.Add($"User {user} already exists"); + catch (Exception e) { + log.Add($"Failed to ban {user}: {e}"); } } - - - StateHasChanged(); - - return ""; + else { + log.Add($"User {user} already exists"); + } } } \ No newline at end of file diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/MembershipHistory.razor b/MatrixUtils.Web/Pages/Tools/Moderation/MembershipHistory.razor
index e5ba004..1ec3cd0 100644 --- a/MatrixUtils.Web/Pages/Tools/Moderation/MembershipHistory.razor +++ b/MatrixUtils.Web/Pages/Tools/Moderation/MembershipHistory.razor
@@ -1,31 +1,162 @@ @page "/Tools/Moderation/MembershipHistory" +@using System.Collections.Frozen @using System.Collections.ObjectModel +@using System.Diagnostics +@using System.Text.Json +@using ArcaneLibs.Extensions @using LibMatrix -@using LibMatrix.EventTypes.Spec.State +@using LibMatrix.EventTypes.Spec.State.RoomInfo +@using LibMatrix.Filters +@{ + var sw = Stopwatch.StartNew(); + Console.WriteLine("Start render"); +} <h3>Membership history viewer</h3> <hr/> - <br/> <span>Room ID: </span> -<InputText @bind-Value="@roomId"></InputText> +<InputText @bind-Value="@RoomId"></InputText> <LinkButton OnClick="@Execute">Execute</LinkButton> -<p><InputCheckbox @bind-Value="ChronologicalOrder"/> Chronological order</p> +<p> + <span><InputCheckbox @bind-Value="ChronologicalOrder"/>Chronological order</span> + <span><InputCheckbox @bind-Value="DoDisambiguate"/>Enable extended filters</span> +</p> <p> <span>Show </span> - <InputCheckbox @bind-Value="ShowJoins"/> joins - <InputCheckbox @bind-Value="ShowLeaves"/> leaves - <InputCheckbox @bind-Value="ShowUpdates"/> profile updates - <InputCheckbox @bind-Value="ShowKnocks"/> knocks - <InputCheckbox @bind-Value="ShowInvites"/> invites - <InputCheckbox @bind-Value="ShowKicks"/> kicks - <InputCheckbox @bind-Value="ShowBans"/> bans + <span><InputCheckbox @bind-Value="ShowJoins"/> joins</span> + <span><InputCheckbox @bind-Value="ShowLeaves"/> leaves</span> + <span><InputCheckbox @bind-Value="ShowKnocks"/> knocks</span> + <span><InputCheckbox @bind-Value="ShowInvites"/> invites</span> + <span><InputCheckbox @bind-Value="ShowBans"/> bans</span> </p> <p> - <LinkButton OnClick="@(async () => { ShowJoins = ShowLeaves = ShowUpdates = ShowKnocks = ShowInvites = ShowKicks = ShowBans = false; })">Hide all</LinkButton> - <LinkButton OnClick="@(async () => { ShowJoins = ShowLeaves = ShowUpdates = ShowKnocks = ShowInvites = ShowKicks = ShowBans = true; })">Show all</LinkButton> - <LinkButton OnClick="@(async () => { ShowJoins ^= true; ShowLeaves ^= true; ShowUpdates ^= true; ShowKnocks ^= true; ShowInvites ^= true; ShowKicks ^= true; ShowBans ^= true; })">Toggle all</LinkButton> + <LinkButton OnClick="@(async () => { + ShowJoins = ShowLeaves = ShowKnocks = ShowInvites = ShowBans = false; + StateHasChanged(); + })">Hide all + </LinkButton> + <LinkButton OnClick="@(async () => { + ShowJoins = ShowLeaves = ShowKnocks = ShowInvites = ShowBans = true; + StateHasChanged(); + })">Show all + </LinkButton> + <LinkButton OnClick="@(async () => { + ShowJoins ^= true; + ShowLeaves ^= true; + ShowKnocks ^= true; + ShowInvites ^= true; + ShowBans ^= true; + StateHasChanged(); + })">Toggle all + </LinkButton> </p> <p> + <span><InputCheckbox @bind-Value="DoDisambiguate"/> Disambiguate </span> + @if (DoDisambiguate) { + <span><InputCheckbox @bind-Value="DisambiguateKicks"/> kicks</span> + <span><InputCheckbox @bind-Value="DisambiguateUnbans"/> unbans</span> + <span><InputCheckbox @bind-Value="DisambiguateProfileUpdates"/> profile updates</span> + <details style="display: inline-block; vertical-align: top;"> + <summary> + <InputCheckbox @bind-Value="DisambiguateInviteActions"/> + invite actions + </summary> + <span><InputCheckbox @bind-Value="DisambiguateInviteAccepted"/> accepted</span> + <span><InputCheckbox @bind-Value="DisambiguateInviteRejected"/> rejected</span> + <span><InputCheckbox @bind-Value="DisambiguateInviteRetracted"/> retracted</span> + </details> + <details style="display: inline-block; vertical-align: top;"> + <summary> + <InputCheckbox @bind-Value="DisambiguateKnockActions"/> + knock actions + </summary> + <span><InputCheckbox @bind-Value="DisambiguateKnockAccepted"/> accepted</span> + <span><InputCheckbox @bind-Value="DisambiguateKnockRejected"/> rejected</span> + <span><InputCheckbox @bind-Value="DisambiguateKnockRetracted"/> retracted</span> + </details> + } +</p> +@if (DoDisambiguate) { + <p> + <span>Show </span> + @if (DisambiguateKicks) { + <span><InputCheckbox @bind-Value="ShowKicks"/> kicks</span> + } + @if (DisambiguateUnbans) { + <span><InputCheckbox @bind-Value="ShowUnbans"/> unbans</span> + } + @if (DisambiguateProfileUpdates) { + <span><InputCheckbox @bind-Value="ShowProfileUpdates"/> profile updates</span> + } + @if (DisambiguateInviteActions) { + <details style="display: inline-block; vertical-align: top;"> + <summary> + <InputCheckbox @bind-Value="ShowInviteActions"/> + invite actions + </summary> + @if (DisambiguateInviteAccepted) { + <span><InputCheckbox @bind-Value="ShowInviteAccepted"/> accepted</span> + } + + @if (DisambiguateInviteRejected) { + <span><InputCheckbox @bind-Value="ShowInviteRejected"/> rejected</span> + } + + @if (DisambiguateInviteRetracted) { + <span><InputCheckbox @bind-Value="ShowInviteRetracted"/> retracted</span> + } + </details> + } + @if (DisambiguateKnockActions) { + <details style="display: inline-block; vertical-align: top;"> + <summary> + <InputCheckbox @bind-Value="ShowKnockActions"/> + knock actions + </summary> + @if (DisambiguateKnockAccepted) { + <span><InputCheckbox @bind-Value="ShowKnockAccepted"/> accepted</span> + } + + @if (DisambiguateKnockRejected) { + <span><InputCheckbox @bind-Value="ShowKnockRejected"/> rejected</span> + } + + @if (DisambiguateKnockRetracted) { + <span><InputCheckbox @bind-Value="ShowKnockRetracted"/> retracted</span> + } + </details> + } + </p> + + <p> + <LinkButton OnClick="@(async () => { + DoDisambiguate = DisambiguateProfileUpdates = DisambiguateKicks = DisambiguateUnbans = DisambiguateInviteAccepted = DisambiguateInviteRejected = DisambiguateInviteRetracted = DisambiguateKnockAccepted = DisambiguateKnockRejected = DisambiguateKnockRetracted = DisambiguateKnockActions = DisambiguateInviteActions = false; + StateHasChanged(); + })">Un-disambiguate all + </LinkButton> + <LinkButton OnClick="@(async () => { + DoDisambiguate = DisambiguateProfileUpdates = DisambiguateKicks = DisambiguateUnbans = DisambiguateInviteAccepted = DisambiguateInviteRejected = DisambiguateInviteRetracted = DisambiguateKnockAccepted = DisambiguateKnockRejected = DisambiguateKnockRetracted = DisambiguateKnockActions = DisambiguateInviteActions = true; + StateHasChanged(); + })">Disambiguate all + </LinkButton> + <LinkButton OnClick="@(async () => { + DisambiguateProfileUpdates ^= true; + DisambiguateKicks ^= true; + DisambiguateUnbans ^= true; + DisambiguateInviteAccepted ^= true; + DisambiguateInviteRejected ^= true; + DisambiguateInviteRetracted ^= true; + DisambiguateKnockAccepted ^= true; + DisambiguateKnockRejected ^= true; + DisambiguateKnockRetracted ^= true; + DisambiguateKnockActions ^= true; + DisambiguateInviteActions ^= true; + StateHasChanged(); + })">Toggle all + </LinkButton> + </p> +} +<p> <span>Sender: </span> <InputSelect @bind-Value="Sender"> <option value="">All</option> @@ -44,92 +175,121 @@ </InputSelect> </p> - +@{ Console.WriteLine($"Rendering took {sw.Elapsed} for {Memberships.Count} items"); } <br/> -<details> +<details open> <summary>Results</summary> @{ - Dictionary<string, StateEventResponse> previousMemberships = []; - var filteredMemberships = Memberships.AsEnumerable(); - if (ChronologicalOrder) { - filteredMemberships = filteredMemberships.Reverse(); - } - if(!string.IsNullOrWhiteSpace(Sender)) { - filteredMemberships = filteredMemberships.Where(x => x.Sender == Sender); - } - if(!string.IsNullOrWhiteSpace(User)) { - filteredMemberships = filteredMemberships.Where(x => x.StateKey == User); - } - - @foreach (var membership in filteredMemberships) { - RoomMemberEventContent content = membership.TypedContent as RoomMemberEventContent; - @switch (content.Membership) { - case RoomMemberEventContent.MembershipTypes.Invite: { - if (_showInvites) { - <p style="color: green;">@membership.Sender invited @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")</p> - } - - break; - } - case RoomMemberEventContent.MembershipTypes.Ban: { - if (_showBans) { - <p style="color: red;">@membership.Sender banned @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")</p> - } - - break; - } - case RoomMemberEventContent.MembershipTypes.Leave: { - if (membership.Sender == membership.StateKey) { - if (_showLeaves) { - <p style="color: #C66;">@membership.Sender left the room</p> - } - } - else { - if (_showKicks) { - <p style="color: darkorange;">@membership.Sender kicked @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")</p> - } - } - - break; - } - case RoomMemberEventContent.MembershipTypes.Knock: { - if (_showKnocks) { - <p>@membership.Sender knocked @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")</p> - } - - break; - } - case RoomMemberEventContent.MembershipTypes.Join: { - if (previousMemberships.TryGetValue(membership.StateKey, out var previous) - && (previous.TypedContent as RoomMemberEventContent).Membership == RoomMemberEventContent.MembershipTypes.Join) { - if (_showUpdates) { - <p style="color: #777;">@membership.Sender changed their profile</p> - } - } - else { - if (_showJoins) { - <p style="color: #6C6;">@membership.Sender joined the room @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")</p> - } + var filteredMemberships = GetFilteredMemberships(); + } + <table> + @foreach (var membershipEntry in filteredMemberships) { + var (transition, membership, previousMembership) = membershipEntry; + RoomMemberEventContent content = membership.TypedContent as RoomMemberEventContent ?? throw new InvalidOperationException("Event is not a RoomMemberEventContent!"); + RoomMemberEventContent? previousContent = previousMembership?.TypedContent as RoomMemberEventContent; + + <tr> + <td>@DateTimeOffset.FromUnixTimeMilliseconds(membership.OriginServerTs ?? 0).ToString("g")</td> + <td> + @switch (transition) { + case MembershipTransition.None: + <b>Unknown membership! Got None</b> + break; + case MembershipTransition.Join: + <p style="color: #6C6;"> + @membership.StateKey joined the room @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})")<br/> + Display name: @content.DisplayName<br/> + Avatar URL: @content.AvatarUrl + </p> + break; + case MembershipTransition.Leave: + <p style="color: #C66;"> + @membership.StateKey left the room + </p> + break; + case MembershipTransition.Knock: + <p style="color: #426"> + @membership.StateKey knocked @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})") + </p> + break; + case MembershipTransition.Invite: + <p style="color: #262;"> + @membership.Sender invited @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})") + </p> + break; + case MembershipTransition.Ban: + <p style="color: red;"> + @membership.Sender banned @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})") + </p> + break; + @* disambiguated *@ + case MembershipTransition.Kick: + <p style="color: darkorange;"> + @membership.Sender kicked @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})") + </p> + break; + case MembershipTransition.ProfileUpdate: + <p style="color: #777;"> + @membership.Sender changed their profile<br/> + Display name: @previousContent!.DisplayName -> @content.DisplayName<br/> + Avatar URL: @previousContent.AvatarUrl -> @content.AvatarUrl + </p> + break; + case MembershipTransition.InviteAccepted: + <p style="color: #084;"> + @membership.StateKey accepted the invite + from @previousMembership!.Sender @(string.IsNullOrWhiteSpace(previousContent?.Reason) ? "" : $"(invite reason: {previousContent.Reason})") @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(accept reason: {content.Reason})") + </p> + break; + case MembershipTransition.KnockAccepted: + <p style="color: #288;"> + @membership.StateKey's knock was accepted + by @previousMembership!.Sender @(string.IsNullOrWhiteSpace(previousContent?.Reason) ? "" : $"(knock reason: {previousContent.Reason})") @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(accept reason: {content.Reason})") + </p> + break; + case MembershipTransition.KnockRejected: + <p style="color: #828;"> + @membership.StateKey's knock was rejected + by @previousMembership!.Sender @(string.IsNullOrWhiteSpace(previousContent?.Reason) ? "" : $"(knock reason: {previousContent.Reason})") @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reject reason: {content.Reason})") + </p> + break; + case MembershipTransition.Unban: + <p style="color: #0C0;"> + @membership.Sender unbanned @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})") + </p> + break; + case MembershipTransition.InviteRejected: + <p style="color: #733;"> + @membership.StateKey rejected the invite + from @previousMembership!.Sender @(string.IsNullOrWhiteSpace(previousContent?.Reason) ? "" : $"(invite reason: {previousContent.Reason})") @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reject reason: {content.Reason})") + </p> + break; + case MembershipTransition.InviteRetracted: + <p style="color: #844;"> + @membership.Sender retracted the invite + for @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})") + </p> + break; + case MembershipTransition.KnockRetracted: + <p style="color: #b55;"> + @membership.Sender retracted the knock + for @membership.StateKey @(string.IsNullOrWhiteSpace(content.Reason) ? "" : $"(reason: {content.Reason})") + </p> + break; + default: + throw new ArgumentOutOfRangeException(); } - - break; - } - default: { - <b>Unknown membership @content.Membership!</b> - break; - } - } - - previousMemberships[membership.StateKey] = membership; + </td> + </tr> } - } + </table> </details> <br/> <details open> <summary>Log</summary> - @foreach (var line in log.Reverse()) { + @foreach (var line in Log.Reverse()) { <pre>@line</pre> } </details> @@ -138,139 +298,289 @@ #region Filter bindings - private bool _chronologicalOrder = false; - - private bool ChronologicalOrder { - get => _chronologicalOrder; - set { - _chronologicalOrder = value; - StateHasChanged(); - } - } + private bool ChronologicalOrder { get; set; } + private bool ShowJoins { get; set; } = true; + private bool ShowLeaves { get; set; } = true; + private bool ShowKnocks { get; set; } = true; + private bool ShowInvites { get; set; } = true; + private bool ShowBans { get; set; } = true; + + private bool DoDisambiguate { get; set; } = true; + private bool DisambiguateProfileUpdates { get => field && DoDisambiguate; set; } = true; + private bool DisambiguateKicks { get => field && DoDisambiguate; set; } = true; + private bool DisambiguateUnbans { get => field && DoDisambiguate; set; } = true; + private bool DisambiguateInviteAccepted { get => field && DoDisambiguate && DisambiguateInviteActions; set; } = true; + private bool DisambiguateInviteRejected { get => field && DoDisambiguate && DisambiguateInviteActions; set; } = true; + private bool DisambiguateInviteRetracted { get => field && DoDisambiguate && DisambiguateInviteActions; set; } = true; + private bool DisambiguateKnockAccepted { get => field && DoDisambiguate && DisambiguateKnockActions; set; } = true; + private bool DisambiguateKnockRejected { get => field && DoDisambiguate && DisambiguateKnockActions; set; } = true; + private bool DisambiguateKnockRetracted { get => field && DoDisambiguate && DisambiguateKnockActions; set; } = true; + + private bool DisambiguateKnockActions { get => field && DoDisambiguate; set; } = true; + private bool DisambiguateInviteActions { get => field && DoDisambiguate; set; } = true; - private bool _showJoins = true; + private bool ShowProfileUpdates { + get => field && DisambiguateProfileUpdates; + set; + } = true; - private bool ShowJoins { - get => _showJoins; + private bool ShowKicks { + get => field && DisambiguateKicks; + set; + } = true; + + private bool ShowUnbans { + get => field && DisambiguateUnbans; + set; + } = true; + + private bool ShowInviteAccepted { + get => field && DisambiguateInviteAccepted; + set; + } = true; + + private bool ShowInviteRejected { + get => field && DisambiguateInviteRejected; + set; + } = true; + + private bool ShowInviteRetracted { + get => field && DisambiguateInviteRetracted; + set; + } = true; + + private bool ShowKnockAccepted { + get => field && DisambiguateKnockAccepted; + set; + } = true; + + private bool ShowKnockRejected { + get => field && DisambiguateKnockRejected; + set; + } = true; + + private bool ShowKnockRetracted { + get => field && DisambiguateKnockRetracted; + set; + } = true; + + private bool ShowKnockActions { + get => field && DisambiguateKnockActions; + set; + } = true; + + private bool ShowInviteActions { + get => field && DisambiguateInviteActions; + set; + } = true; + + [Parameter, SupplyParameterFromQuery(Name = "sender")] + public string Sender { get; set; } = ""; + + [Parameter, SupplyParameterFromQuery(Name = "user")] + public string User { get; set; } = ""; + + [Parameter, SupplyParameterFromQuery(Name = "filter")] + public string Filter { + get; set { - _showJoins = value; + field = value; + if (string.IsNullOrWhiteSpace(value)) return; + var parts = value.Split(','); + ShowJoins = parts.Contains("join"); + ShowLeaves = parts.Contains("leave"); + ShowKnocks = parts.Contains("knock"); + ShowInvites = parts.Contains("invite"); + ShowBans = parts.Contains("ban"); StateHasChanged(); } - } - - private bool _showLeaves = true; + } = ""; - private bool ShowLeaves { - get => _showLeaves; - set { - _showLeaves = value; - StateHasChanged(); - } - } +#endregion - private bool _showUpdates = true; + private ObservableCollection<string> Log { get; set; } = new(); + private List<StateEventResponse> Memberships { get; set; } = []; + private AuthenticatedHomeserverGeneric Homeserver { get; set; } - private bool ShowUpdates { - get => _showUpdates; - set { - _showUpdates = value; - StateHasChanged(); - } - } + [Parameter, SupplyParameterFromQuery(Name = "room")] + public string RoomId { get; set; } = ""; - private bool _showKnocks = true; + protected override async Task OnInitializedAsync() { + Log.CollectionChanged += (sender, args) => StateHasChanged(); + Homeserver = await sessionStore.GetCurrentHomeserver(navigateOnFailure: true); + if (Homeserver is null) return; - private bool ShowKnocks { - get => _showKnocks; - set { - _showKnocks = value; - StateHasChanged(); - } + StateHasChanged(); + Console.WriteLine("Rerendered!"); + await base.OnInitializedAsync(); + if (!string.IsNullOrWhiteSpace(RoomId)) + await Execute(); } - private bool _showInvites = true; + private async Task Execute() { + Memberships.Clear(); + var room = Homeserver.GetRoom(RoomId); + var filter = new SyncFilter.EventFilter() { Types = [RoomMemberEventContent.EventId] }; + var events = room.GetManyMessagesAsync(limit: int.MaxValue, filter: filter.ToJson(ignoreNull: true, indent: false)); + await foreach (var resp in events) { + var all = resp.State.Concat(resp.Chunk) + // ugly hack, because some users fuck around too much + .Select(x => { + if (x.RawContent?["displayname"]?.GetValueKind() != JsonValueKind.String) + x.RawContent?.Remove("displayname"); + if (x.RawContent?["avatar_url"]?.GetValueKind() is not JsonValueKind.String) + x.RawContent?.Remove("avatar_url"); + return x; + }); + Memberships.AddRange(all.Where(x => x.Type == RoomMemberEventContent.EventId)); - private bool ShowInvites { - get => _showInvites; - set { - _showInvites = value; - StateHasChanged(); + Log.Add($"Got {resp.State.Count} state and {resp.Chunk.Count} timeline events."); } - } - private bool _showKicks = true; + Log.Add("Reached end of timeline!"); - private bool ShowKicks { - get => _showKicks; - set { - _showKicks = value; - StateHasChanged(); - } + StateHasChanged(); } - private bool _showBans = true; + private readonly struct MembershipEntry { + public required MembershipTransition State { get; init; } + public required StateEventResponse Event { get; init; } + public required StateEventResponse? Previous { get; init; } - private bool ShowBans { - get => _showBans; - set { - _showBans = value; - StateHasChanged(); + public void Deconstruct(out MembershipTransition transition, out StateEventResponse evt, out StateEventResponse? prev) { + transition = State; + evt = Event; + prev = Previous; } } - - private string sender = ""; - - private string Sender { - get => sender; - set { - sender = value; - StateHasChanged(); - } + + private enum MembershipTransition : byte { + None, + Join, + Leave, + Knock, + Invite, + Ban, + + // disambiguated + ProfileUpdate, + Kick, + Unban, + InviteAccepted, + InviteRejected, + InviteRetracted, + KnockAccepted, + KnockRejected, + KnockRetracted } - - private string user = ""; - - private string User { - get => user; - set { - user = value; - StateHasChanged(); + + private static IEnumerable<MembershipEntry> GetTransitions(List<StateEventResponse> evts) { + Dictionary<string, MembershipEntry> transitions = new(); + foreach (var evt in evts.OrderBy(x => x.OriginServerTs)) { + var content = evt.TypedContent as RoomMemberEventContent ?? throw new InvalidOperationException("Event is not a RoomMemberEventContent!"); + var prev = transitions.GetValueOrDefault(evt.StateKey!) as MembershipEntry?; + transitions[evt.StateKey ?? throw new Exception("Member event has no state key??")] = new MembershipEntry { + Event = evt, + Previous = prev?.Event, + State = content.Membership switch { + RoomMemberEventContent.MembershipTypes.Join => + prev?.State switch { + MembershipTransition.Join or MembershipTransition.InviteAccepted => MembershipTransition.ProfileUpdate, + MembershipTransition.Invite => MembershipTransition.InviteAccepted, + _ => MembershipTransition.Join + }, + RoomMemberEventContent.MembershipTypes.Leave => + evt.Sender == evt.StateKey + ? prev?.State switch { + MembershipTransition.Knock => MembershipTransition.KnockRetracted, + MembershipTransition.Invite => MembershipTransition.InviteRejected, + _ => MembershipTransition.Leave + } + : prev?.State switch { + // not self + MembershipTransition.Knock => MembershipTransition.KnockRejected, + MembershipTransition.Invite => MembershipTransition.InviteRetracted, + _ => MembershipTransition.Kick, + }, + RoomMemberEventContent.MembershipTypes.Invite => + prev?.State switch { + MembershipTransition.Knock => MembershipTransition.KnockAccepted, + _ => MembershipTransition.Invite + }, + RoomMemberEventContent.MembershipTypes.Knock => MembershipTransition.Knock, + RoomMemberEventContent.MembershipTypes.Ban => MembershipTransition.Ban, + _ => MembershipTransition.None + } + }; + yield return transitions[evt.StateKey]; } } -#endregion - - private ObservableCollection<string> log { get; set; } = new(); - private List<StateEventResponse> Memberships { get; set; } = []; - private AuthenticatedHomeserverGeneric hs { get; set; } - - [Parameter, SupplyParameterFromQuery(Name = "room")] - public string roomId { get; set; } - - protected override async Task OnInitializedAsync() { - log.CollectionChanged += (sender, args) => StateHasChanged(); - hs = await RMUStorage.GetCurrentSessionOrNavigate(); - if (hs is null) return; + private IEnumerable<MembershipEntry> Disambiguated(IEnumerable<MembershipEntry> entries) { + FrozenDictionary<MembershipTransition, MembershipTransition> disambiguated = new Dictionary<MembershipTransition, MembershipTransition>() { + { MembershipTransition.ProfileUpdate, MembershipTransition.Join }, + { MembershipTransition.Kick, MembershipTransition.Leave }, + { MembershipTransition.Unban, MembershipTransition.Leave }, + { MembershipTransition.InviteAccepted, MembershipTransition.Join }, + { MembershipTransition.InviteRejected, MembershipTransition.Leave }, + { MembershipTransition.InviteRetracted, MembershipTransition.Leave }, + { MembershipTransition.KnockAccepted, MembershipTransition.Invite }, + { MembershipTransition.KnockRejected, MembershipTransition.Leave }, + { MembershipTransition.KnockRetracted, MembershipTransition.Leave } + }.ToFrozenDictionary(); + + foreach (var entry in entries) { + if (!DoDisambiguate) { + yield return entry; + continue; + } - StateHasChanged(); - Console.WriteLine("Rerendered!"); - await base.OnInitializedAsync(); - if (!string.IsNullOrWhiteSpace(roomId)) - await Execute(); + var newState = entry.State switch { + MembershipTransition.ProfileUpdate when !DoDisambiguate || !DisambiguateProfileUpdates => MembershipTransition.Join, + MembershipTransition.Kick when !DoDisambiguate || !DisambiguateKicks => MembershipTransition.Leave, + MembershipTransition.Unban when !DoDisambiguate || !DisambiguateUnbans => MembershipTransition.Leave, + MembershipTransition.InviteAccepted when !DoDisambiguate || !DisambiguateInviteActions || !DisambiguateInviteAccepted => MembershipTransition.Join, + MembershipTransition.InviteRejected when !DoDisambiguate || !DisambiguateInviteActions || !DisambiguateInviteRejected => MembershipTransition.Leave, + MembershipTransition.InviteRetracted when !DoDisambiguate || !DisambiguateInviteActions || !DisambiguateInviteRetracted => MembershipTransition.Leave, + MembershipTransition.KnockAccepted when !DoDisambiguate || !DisambiguateKnockActions || !DisambiguateKnockAccepted => MembershipTransition.Invite, + MembershipTransition.KnockRejected when !DoDisambiguate || !DisambiguateKnockActions || !DisambiguateKnockRejected => MembershipTransition.Leave, + MembershipTransition.KnockRetracted when !DoDisambiguate || !DisambiguateKnockActions || !DisambiguateKnockRetracted => MembershipTransition.Leave, + _ => entry.State + }; + if (newState != entry.State) { + yield return entry with { State = newState }; + } + else yield return entry; + } } - private async Task Execute() { - Memberships.Clear(); - var room = hs.GetRoom(roomId); - var events = room.GetManyMessagesAsync(limit: int.MaxValue, chunkSize: 5000); - await foreach (var resp in events) { - var all = resp.State.Concat(resp.Chunk); - Memberships.AddRange(all.Where(x => x.Type == RoomMemberEventContent.EventId)); - - log.Add($"{resp.State.Count} state, {resp.Chunk.Count} timeline"); + private IEnumerable<MembershipEntry> GetFilteredMemberships() { + var filteredMemberships = GetTransitions(Memberships); + if (!string.IsNullOrWhiteSpace(Sender)) filteredMemberships = filteredMemberships.Where(x => x.Event.Sender == Sender); + if (!string.IsNullOrWhiteSpace(User)) filteredMemberships = filteredMemberships.Where(x => x.Event.StateKey == User); + filteredMemberships = Disambiguated(filteredMemberships); + + if (!ShowJoins) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.Join); + if (!ShowLeaves) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.Leave); + if (!ShowKnocks) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.Knock); + if (!ShowInvites) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.Invite); + if (!ShowBans) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.Ban); + // extended filters + if (DoDisambiguate) { + if (!DisambiguateProfileUpdates || !ShowProfileUpdates) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.ProfileUpdate); + if (!DisambiguateKicks || !ShowKicks) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.Kick); + if (!DisambiguateUnbans || !ShowUnbans) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.Unban); + if (!DisambiguateInviteActions || !ShowInviteActions || !DisambiguateInviteAccepted || !ShowInviteAccepted) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.InviteAccepted); + if (!DisambiguateInviteActions || !ShowInviteActions || !DisambiguateInviteRejected || !ShowInviteRejected) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.InviteRejected); + if (!DisambiguateInviteActions || !ShowInviteActions || !DisambiguateInviteRetracted || !ShowInviteRetracted) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.InviteRetracted); + if (!DisambiguateKnockActions || !ShowKnockActions || !DisambiguateKnockAccepted || !ShowKnockAccepted) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.KnockAccepted); + if (!DisambiguateKnockActions || !ShowKnockActions || !DisambiguateKnockRejected || !ShowKnockRejected) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.KnockRejected); + if (!DisambiguateKnockActions || !ShowKnockActions || !DisambiguateKnockRetracted || !ShowKnockRetracted) filteredMemberships = filteredMemberships.Where(x => x.State != MembershipTransition.KnockRetracted); } - StateHasChanged(); + if (!ChronologicalOrder) filteredMemberships = filteredMemberships.Reverse(); + + return filteredMemberships; } } \ No newline at end of file diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/RoomIntersections.razor b/MatrixUtils.Web/Pages/Tools/Moderation/RoomIntersections.razor
index b8baeb8..736e59a 100644 --- a/MatrixUtils.Web/Pages/Tools/Moderation/RoomIntersections.razor +++ b/MatrixUtils.Web/Pages/Tools/Moderation/RoomIntersections.razor
@@ -2,7 +2,7 @@ @using LibMatrix.RoomTypes @using System.Collections.ObjectModel @using LibMatrix -@using LibMatrix.EventTypes.Spec.State +@using LibMatrix.EventTypes.Spec.State.RoomInfo <h3>Room intersections</h3> <hr/> @@ -113,7 +113,7 @@ protected override async Task OnInitializedAsync() { Log.CollectionChanged += (sender, args) => StateHasChanged(); - hs = await RMUStorage.GetCurrentSessionOrNavigate(); + hs = await sessionStore.GetCurrentHomeserver(navigateOnFailure: true); if (hs is null) return; StateHasChanged(); diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/UserTrace.razor b/MatrixUtils.Web/Pages/Tools/Moderation/UserTrace.razor
index 915f8dc..c3cc09c 100644 --- a/MatrixUtils.Web/Pages/Tools/Moderation/UserTrace.razor +++ b/MatrixUtils.Web/Pages/Tools/Moderation/UserTrace.razor
@@ -3,13 +3,15 @@ @using LibMatrix.RoomTypes @using System.Collections.ObjectModel @using LibMatrix +@using LibMatrix.EventTypes.Spec.State.RoomInfo <h3>User Trace</h3> <hr/> <p>Users: </p> <InputTextArea @bind-Value="@UserIdString"></InputTextArea> <br/> -<InputText @bind-Value="@ImportFromRoomId"></InputText><LinkButton OnClick="@DoImportFromRoomId">Import from room (ID)</LinkButton> +<InputText @bind-Value="@ImportFromRoomId"></InputText> +<LinkButton OnClick="@DoImportFromRoomId">Import from room (ID)</LinkButton> <details> <summary>Rooms to be searched (@rooms.Count)</summary> @@ -24,18 +26,21 @@ <details> <summary>Results</summary> - @foreach (var (userId, events) in matches) { + @foreach (var (userId, events) in matches.OrderBy(x=>x.Key)) { <h4>@userId</h4> - <ul> - @foreach (var match in events) { - <li> - <ul> - <li>@match.RoomName (<span>@match.Room.RoomId</span>)</li> - <li>Membership: @(match.Event.RawContent.ToJson(indent: false))</li> - </ul> - </li> + <table> + @foreach (var match in events.OrderBy(x=>x.RoomName)) { + <tr> + <td>@match.RoomName (<span>@match.Room.RoomId</span>)</td> + <td> + <details> + <summary>@SummarizeMembership(match.Event)</summary> + <pre>@match.Event.RawContent.ToJson(indent: true)</pre> + </details> + </td> + </tr> } - </ul> + </table> } </details> @@ -61,56 +66,34 @@ protected override async Task OnInitializedAsync() { log.CollectionChanged += (sender, args) => StateHasChanged(); - var hs = await RMUStorage.GetCurrentSessionOrNavigate(); + var hs = await sessionStore.GetCurrentHomeserver(navigateOnFailure: true); if (hs is null) return; - // var sessions = await RMUStorage.GetAllTokens(); - // var baseRooms = new List<GenericRoom>(); - // foreach (var userAuth in sessions) { - // var session = await RMUStorage.GetSession(userAuth); - // if (session is not null) { - // baseRooms.AddRange(await session.GetJoinedRooms()); - // var sessionRooms = (await session.GetJoinedRooms()).Where(x => !rooms.Any(y => y.Room.RoomId == x.RoomId)).ToList(); - // StateHasChanged(); - // log.Add($"Got {sessionRooms.Count} rooms for {userAuth.UserId}"); - // } - // } - // - // log.Add("Done fetching rooms!"); - // - // baseRooms = baseRooms.DistinctBy(x => x.RoomId).ToList(); - // - // // rooms.CollectionChanged += (sender, args) => StateHasChanged(); - // var tasks = baseRooms.Select(async newRoom => { - // bool success = false; - // while (!success) - // try { - // var state = await newRoom.GetFullStateAsListAsync(); - // var newRoomInfo = new RoomInfo(newRoom, state); - // rooms.Add(newRoomInfo); - // log.Add($"Got {newRoomInfo.StateEvents.Count} events for {newRoomInfo.RoomName}"); - // success = true; - // } - // catch (MatrixException e) { - // log.Add($"Failed to fetch room {newRoom.RoomId}! {e}"); - // throw; - // } - // catch (HttpRequestException e) { - // log.Add($"Failed to fetch room {newRoom.RoomId}! {e}"); - // } - // }); - // await Task.WhenAll(tasks); - // - // log.Add($"Done fetching members!"); - // - // UserIDs.RemoveAll(x => sessions.Any(y => y.UserId == x)); - - foreach (var session in await RMUStorage.GetAllTokens()) { - var _hs = await RMUStorage.GetSession(session); - if (_hs is not null) { - rooms.AddRange(await _hs.GetJoinedRooms()); - log.Add($"Got {rooms.Count} rooms after adding {_hs.UserId}"); + + var sessions = await sessionStore.GetAllSessions(); + var tasks = sessions.Select(async session => { + try { + var _hs = await sessionStore.GetHomeserver(session.Key); + if (_hs is not null) { + try { + var _rooms = await _hs.GetJoinedRooms(); + if (!_rooms.Any()) return; + // Check if homeserver supports `?format=event`: + await _rooms.First().GetStateEventAsync(RoomMemberEventContent.EventId, session.Value.Auth.UserId); + rooms.AddRange(_rooms); + log.Add($"Got {_rooms.Count} rooms for {_hs.UserId}, total {rooms.Count}"); + } + catch (Exception e) { + if (e is LibMatrixException { ErrorCode: LibMatrixException.ErrorCodes.M_UNSUPPORTED }) + log.Add($"Homeserver {_hs.UserId} does not support `?format=event`! Skipping..."); + else log.Add($"Failed to fetch rooms for {_hs.UserId}! {e}"); + } + } } - } + catch (Exception e) { + log.Add($"Failed to fetch rooms for {session.Value.Auth.UserId}! {e}"); + } + }); + await Task.WhenAll(tasks); //get distinct rooms evenly distributed per session, accounting for count per session rooms = rooms.OrderBy(x => rooms.Count(y => y.Homeserver == x.Homeserver)).DistinctBy(x => x.RoomId).ToList(); @@ -125,17 +108,6 @@ foreach (var userId in UserIDs) { matches.Add(userId, new List<Match>()); - // foreach (var room in rooms) { - // var state = room.StateEvents.Where(x => x!.Type == RoomMemberEventContent.EventId).ToList(); - // if (state!.Any(x => x.StateKey == userId)) { - // matches[userId].Add(new() { - // Event = state.First(x => x.StateKey == userId), - // Room = room.Room, - // RoomName = room.RoomName ?? "No name" - // }); - // } - // } - log.Add($"Searching for {userId}..."); await foreach (var match in GetMatches(userId)) { matches[userId].Add(match); @@ -173,13 +145,19 @@ private async IAsyncEnumerable<Match> GetMatches(string userId) { var results = rooms.Select(async room => { - var state = await room.GetStateEventOrNullAsync(room.RoomId, userId); - if (state is not null) { - return new Match { - Room = room, - Event = state, - RoomName = await room.GetNameOrFallbackAsync() - }; + try { + var state = await room.GetStateEventOrNullAsync(RoomMemberEventContent.EventId, userId); + if (state is not null) { + log.Add($"Found {userId} in {room.RoomId} with membership {state.RawContent.ToJson(indent: false)}"); + return new Match { + Room = room, + Event = state, + RoomName = await room.GetNameOrFallbackAsync() + }; + } + } + catch (Exception e) { + log.Add($"Failed to fetch state for {userId} in {room.RoomId}! {e}"); } return null; @@ -191,4 +169,27 @@ } } + public string SummarizeMembership(StateEventResponse state) { + var membership = state.ContentAs<RoomMemberEventContent>(); + var time = DateTimeOffset.FromUnixTimeMilliseconds(state.OriginServerTs!.Value); + return membership switch { + { Membership: "invite", Reason: null } => $"Invited by {state.Sender} at {time}", + { Membership: "invite", Reason: not null } => $"Invited by {state.Sender} at {time} for {membership.Reason}", + { Membership: "join", Reason: null } => $"Joined at {time}", + { Membership: "join", Reason: not null } => $"Joined at {time} for {membership.Reason}", + { Membership: "leave", Reason: null } => state.Sender == state.StateKey ? $"Left at {time}" : $"Kicked by {state.Sender} at {time}", + { Membership: "leave", Reason: not null } => state.Sender == state.StateKey ? $"Left at {time} with reason {membership.Reason}" : $"Kicked by {state.Sender} at {time} for {membership.Reason}", + { Membership: "ban", Reason: null } => $"Banned by {state.Sender} at {time}", + { Membership: "ban", Reason: not null } => $"Banned by {state.Sender} at {time} for {membership.Reason}", + { Membership: "knock", Reason: null } => $"Knocked at {time}", + { Membership: "knock", Reason: not null } => $"Knocked at {time} for {membership.Reason}", + _ => $"Unknown membership {membership.Membership}, sent at {time} by {state.Sender} for {membership.Reason}" + }; + } + + private async Task ExportJson() { + var json = matches.ToJson(); + + } + } \ No newline at end of file