diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/DraupnirProtectedRoomsEditor.razor b/MatrixUtils.Web/Pages/Tools/Moderation/DraupnirProtectedRoomsEditor.razor
new file mode 100644
index 0000000..805bd40
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Tools/Moderation/DraupnirProtectedRoomsEditor.razor
@@ -0,0 +1,102 @@
+@page "/Moderation/DraupnirProtectedRoomsEditor"
+@page "/Tools/Moderation/DraupnirProtectedRoomsEditor"
+@using System.Text.Json.Serialization
+@using LibMatrix.EventTypes.Spec.State
+@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 RMUStorage.GetCurrentSessionOrNavigate();
+ 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);
+ 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/InviteCounter.razor b/MatrixUtils.Web/Pages/Tools/Moderation/InviteCounter.razor
new file mode 100644
index 0000000..2123d4d
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Tools/Moderation/InviteCounter.razor
@@ -0,0 +1,68 @@
+@page "/Tools/Moderation/InviteCounter"
+@using System.Collections.ObjectModel
+@using LibMatrix.EventTypes.Spec.State
+<h3>Invite counter</h3>
+<hr/>
+
+<br/>
+<span>Room ID: </span>
+<InputText @bind-Value="@roomId"></InputText>
+<LinkButton OnClick="@Execute">Execute</LinkButton>
+
+<br/>
+
+<details>
+ <summary>Results</summary>
+ @foreach (var (userId, events) in invites.OrderByDescending(x=>x.Value).ToList()) {
+ <p>@userId: @events</p>
+ }
+</details>
+
+<br/>
+@foreach (var line in log.Reverse()) {
+ <pre>@line</pre>
+}
+
+@code {
+ 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();
+ if (hs is null) return;
+
+ StateHasChanged();
+ Console.WriteLine("Rerendered!");
+ await base.OnInitializedAsync();
+ }
+
+ private async Task<string> Execute() {
+ var room = hs.GetRoom(roomId);
+ var events = room.GetManyMessagesAsync(limit: int.MaxValue);
+ await foreach (var resp in events) {
+ var all = resp.State.Concat(resp.Chunk);
+ foreach (var evt in all) {
+ 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]++;
+ }
+
+ log.Add($"{resp.State.Count} state, {resp.Chunk.Count} timeline");
+ }
+
+
+
+ StateHasChanged();
+
+ return "";
+ }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/MassCMEBan.razor b/MatrixUtils.Web/Pages/Tools/Moderation/MassCMEBan.razor
new file mode 100644
index 0000000..ea1e5f6
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Tools/Moderation/MassCMEBan.razor
@@ -0,0 +1,69 @@
+@page "/Tools/Moderation/MassCMEBan"
+@using System.Collections.ObjectModel
+@using LibMatrix.EventTypes.Spec.State.Policy
+<h3>User Trace</h3>
+<hr/>
+
+<br/>
+<span>Users:</span>
+<InputTextArea @bind-Value="@roomId"></InputTextArea>
+<LinkButton OnClick="@Execute">Execute</LinkButton>
+
+<br/>
+
+<br/>
+@foreach (var line in log.Reverse()) {
+ <pre>@line</pre>
+}
+
+@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();
+ if (hs is null) return;
+
+ StateHasChanged();
+ Console.WriteLine("Rerendered!");
+ await base.OnInitializedAsync();
+ }
+
+ 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}");
+ }
+
+ if (!exists) {
+ var evt = await room.SendStateEventAsync(UserPolicyRuleEventContent.EventId, user.Replace('@', '_'), new UserPolicyRuleEventContent() {
+ Entity = user,
+ Reason = "spam (invite)",
+ Recommendation = "m.ban"
+ });
+ log.Add($"Sent {evt.EventId} to ban {user}");
+ }
+ else {
+ log.Add($"User {user} already exists");
+ }
+ }
+
+
+ StateHasChanged();
+
+ return "";
+ }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/MembershipHistory.razor b/MatrixUtils.Web/Pages/Tools/Moderation/MembershipHistory.razor
new file mode 100644
index 0000000..e5ba004
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Tools/Moderation/MembershipHistory.razor
@@ -0,0 +1,276 @@
+@page "/Tools/Moderation/MembershipHistory"
+@using System.Collections.ObjectModel
+@using LibMatrix
+@using LibMatrix.EventTypes.Spec.State
+<h3>Membership history viewer</h3>
+<hr/>
+
+<br/>
+<span>Room ID: </span>
+<InputText @bind-Value="@roomId"></InputText>
+<LinkButton OnClick="@Execute">Execute</LinkButton>
+<p><InputCheckbox @bind-Value="ChronologicalOrder"/> Chronological order</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
+</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>
+</p>
+<p>
+ <span>Sender: </span>
+ <InputSelect @bind-Value="Sender">
+ <option value="">All</option>
+ @foreach (var sender in Memberships.Select(x => x.Sender).Distinct()) {
+ <option value="@sender">@sender</option>
+ }
+ </InputSelect>
+</p>
+<p>
+ <span>User: </span>
+ <InputSelect @bind-Value="User">
+ <option value="">All</option>
+ @foreach (var user in Memberships.Select(x => x.StateKey).Distinct()) {
+ <option value="@user">@user</option>
+ }
+ </InputSelect>
+</p>
+
+
+<br/>
+
+<details>
+ <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>
+ }
+ }
+
+ break;
+ }
+ default: {
+ <b>Unknown membership @content.Membership!</b>
+ break;
+ }
+ }
+
+ previousMemberships[membership.StateKey] = membership;
+ }
+ }
+</details>
+
+<br/>
+<details open>
+ <summary>Log</summary>
+ @foreach (var line in log.Reverse()) {
+ <pre>@line</pre>
+ }
+</details>
+
+@code {
+
+#region Filter bindings
+
+ private bool _chronologicalOrder = false;
+
+ private bool ChronologicalOrder {
+ get => _chronologicalOrder;
+ set {
+ _chronologicalOrder = value;
+ StateHasChanged();
+ }
+ }
+
+ private bool _showJoins = true;
+
+ private bool ShowJoins {
+ get => _showJoins;
+ set {
+ _showJoins = value;
+ StateHasChanged();
+ }
+ }
+
+ private bool _showLeaves = true;
+
+ private bool ShowLeaves {
+ get => _showLeaves;
+ set {
+ _showLeaves = value;
+ StateHasChanged();
+ }
+ }
+
+ private bool _showUpdates = true;
+
+ private bool ShowUpdates {
+ get => _showUpdates;
+ set {
+ _showUpdates = value;
+ StateHasChanged();
+ }
+ }
+
+ private bool _showKnocks = true;
+
+ private bool ShowKnocks {
+ get => _showKnocks;
+ set {
+ _showKnocks = value;
+ StateHasChanged();
+ }
+ }
+
+ private bool _showInvites = true;
+
+ private bool ShowInvites {
+ get => _showInvites;
+ set {
+ _showInvites = value;
+ StateHasChanged();
+ }
+ }
+
+ private bool _showKicks = true;
+
+ private bool ShowKicks {
+ get => _showKicks;
+ set {
+ _showKicks = value;
+ StateHasChanged();
+ }
+ }
+
+ private bool _showBans = true;
+
+ private bool ShowBans {
+ get => _showBans;
+ set {
+ _showBans = value;
+ StateHasChanged();
+ }
+ }
+
+ private string sender = "";
+
+ private string Sender {
+ get => sender;
+ set {
+ sender = value;
+ StateHasChanged();
+ }
+ }
+
+ private string user = "";
+
+ private string User {
+ get => user;
+ set {
+ user = value;
+ StateHasChanged();
+ }
+ }
+
+#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;
+
+ StateHasChanged();
+ Console.WriteLine("Rerendered!");
+ await base.OnInitializedAsync();
+ if (!string.IsNullOrWhiteSpace(roomId))
+ await Execute();
+ }
+
+ 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");
+ }
+
+ StateHasChanged();
+ }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/RoomIntersections.razor b/MatrixUtils.Web/Pages/Tools/Moderation/RoomIntersections.razor
new file mode 100644
index 0000000..b8baeb8
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Tools/Moderation/RoomIntersections.razor
@@ -0,0 +1,197 @@
+@page "/Tools/Moderation/RoomIntersections"
+@using LibMatrix.RoomTypes
+@using System.Collections.ObjectModel
+@using LibMatrix
+@using LibMatrix.EventTypes.Spec.State
+<h3>Room intersections</h3>
+<hr/>
+
+<p>Set A: </p>
+<InputText @bind-Value="@ImportSetASpaceId"></InputText>
+<LinkButton OnClick="@(() => AppendSet(ImportSetASpaceId, RoomsA))">Append Set A</LinkButton>
+
+<p>Set B: </p>
+<InputText @bind-Value="@ImportSetBSpaceId"></InputText>
+<LinkButton OnClick="@(() => AppendSet(ImportSetBSpaceId, RoomsB))">Append Set B</LinkButton>
+<br/>
+<LinkButton OnClick="@Execute">Execute</LinkButton>
+<br/>
+
+<details>
+ <summary>Results</summary>
+ <pre>
+ @{
+ var userColWidth = matches.Count == 0 ? 0 : matches.Keys.Max(x => x.Length);
+ }
+ <table border="1">
+ @foreach (var (userId, sets) in matches) {
+ <tr>
+ <td>@userId.PadRight(userColWidth + 5)</td>
+ <td>@sets.Item1[0].Room.RoomId</td>
+ <td>@((sets.Item1[0].Member.TypedContent as RoomMemberEventContent).Membership)</td>
+ <td>@(roomNames.ContainsKey(sets.Item1[0].Room) ? roomNames[sets.Item1[0].Room] : "")</td>
+ <td>@(roomAliasses.ContainsKey(sets.Item1[0].Room) ? roomAliasses[sets.Item1[0].Room] : "")</td>
+ <td>@sets.Item2[0].Room.RoomId</td>
+ <td>@((sets.Item2[0].Member.TypedContent as RoomMemberEventContent).Membership)</td>
+ <td>@(roomNames.ContainsKey(sets.Item2[0].Room) ? roomNames[sets.Item2[0].Room] : "")</td>
+ <td>@(roomAliasses.ContainsKey(sets.Item2[0].Room) ? roomAliasses[sets.Item2[0].Room] : "")</td>
+ </tr>
+ @for (int i = 1; i < Math.Max(sets.Item1.Count, sets.Item2.Count); i++) {
+ <tr>
+ <td/>
+ @if (sets.Item1.Count > i) {
+ <td>@sets.Item1[i].Room.RoomId</td>
+ <td>@((sets.Item1[i].Member.TypedContent as RoomMemberEventContent).Membership)</td>
+ <td>@(roomNames.ContainsKey(sets.Item1[i].Room) ? roomNames[sets.Item1[i].Room] : "")</td>
+ <td>@(roomAliasses.ContainsKey(sets.Item1[i].Room) ? roomAliasses[sets.Item1[i].Room] : "")</td>
+ }
+ else {
+ <td/>
+ <td/>
+ <td/>
+ <td/>
+ }
+ @if (sets.Item2.Count > i) {
+ <td>@sets.Item2[0].Room.RoomId</td>
+ <td>@((sets.Item2[i].Member.TypedContent as RoomMemberEventContent).Membership)</td>
+ <td>@(roomNames.ContainsKey(sets.Item2[i].Room) ? roomNames[sets.Item2[i].Room] : "")</td>
+ <td>@(roomAliasses.ContainsKey(sets.Item2[i].Room) ? roomAliasses[sets.Item2[i].Room] : "")</td>
+ }
+ else {
+ <td/>
+ <td/>
+ <td/>
+ <td/>
+ }
+ </tr>
+ }
+ }
+
+ </table>
+ <br/>
+ </pre>
+</details>
+
+<br/>
+@foreach (var line in Log.Reverse()) {
+ <pre>@line</pre>
+}
+
+@code {
+ private ObservableCollection<string> Log { get; set; } = new();
+ List<GenericRoom> RoomsA { get; set; } = new();
+ List<GenericRoom> RoomsB { get; set; } = new();
+
+ [Parameter, SupplyParameterFromQuery(Name = "a")]
+ public string ImportSetASpaceId { get; set; } = "";
+
+ [Parameter, SupplyParameterFromQuery(Name = "b")]
+ public string ImportSetBSpaceId { get; set; } = "";
+
+ Dictionary<string, Dictionary<GenericRoom, StateEventResponse>> roomMembers { get; set; } = new();
+
+ Dictionary<string, (List<Match>, List<Match>)> matches { get; set; } = new();
+
+ AuthenticatedHomeserverGeneric hs { get; set; }
+
+ // private string RoomListAString {
+ // get => string.Join("\n", RoomIdsA);
+ // set => RoomIdsA = value.Split("\n").Select(x => x.Trim()).Where(x => !string.IsNullOrWhiteSpace(x)).ToList();
+ // }
+ //
+ // private string RoomListBString {
+ // get => string.Join("\n", RoomIdsB);
+ // set => RoomIdsB = value.Split("\n").Select(x => x.Trim()).Where(x => !string.IsNullOrWhiteSpace(x)).ToList();
+ // }
+
+ // private List<string> RoomIdsA { get; set; } = new();
+ // private List<string> RoomIdsB { get; set; } = new();
+
+ // room info
+ Dictionary<GenericRoom, string> roomNames { get; set; } = new();
+ Dictionary<GenericRoom, string?> roomAliasses { get; set; } = new();
+
+ protected override async Task OnInitializedAsync() {
+ Log.CollectionChanged += (sender, args) => StateHasChanged();
+ hs = await RMUStorage.GetCurrentSessionOrNavigate();
+ if (hs is null) return;
+
+ StateHasChanged();
+ Console.WriteLine("Rerendered!");
+ await base.OnInitializedAsync();
+ }
+
+ private async Task Execute() {
+ // get all users which are in any room of both sets of rooms, and which rooms
+ var setAusers = new Dictionary<string, List<Match>>();
+ var setBusers = new Dictionary<string, List<Match>>();
+
+ await Task.WhenAll(GetMembers(RoomsA, setAusers), GetMembers(RoomsB, setBusers));
+
+ Log.Add($"Got {setAusers.Count} users in set A");
+ Log.Add($"Got {setBusers.Count} users in set B");
+ Log.Add("Calculating intersections...");
+
+ // get all users which are in both sets of rooms
+ // var users = setAusers.Keys.Intersect(setBusers.Keys).ToList();
+ // var groups = setAusers.IntersectBy(setBusers, (x,y) => x.Key).ToList();
+ matches = setAusers.Keys.Intersect(setBusers.Keys).Select(x => (x, setAusers[x], setBusers[x])).ToDictionary(x => x.x, x => (x.Item2, x.Item3));
+
+ Log.Add($"Found {matches.Count} users in both sets of rooms");
+ StateHasChanged();
+ }
+
+ public async Task GetMembers(List<GenericRoom> rooms, Dictionary<string, List<Match>> users) {
+ foreach (var room in rooms) {
+ Log.Add($"Getting members for {room.RoomId}");
+ var members = await room.GetMembersListAsync(false);
+ foreach (var member in members) {
+ if (member.RawContent?["membership"]?.ToString() == "ban") continue;
+ if (member.RawContent?["membership"]?.ToString() == "invite") continue;
+ if (!users.ContainsKey(member.StateKey)) users[member.StateKey] = new();
+ users[member.StateKey].Add(new() {
+ Room = room,
+ Member = member
+ });
+ }
+ }
+ }
+
+ public async Task AppendSet(string spaceId, List<GenericRoom> rooms) {
+ var space = hs.GetRoom(spaceId).AsSpace;
+ Log.Add($"Found space {spaceId}");
+ var roomIdsEnum = space.GetChildrenAsync(true);
+ List<Task> tasks = new();
+ await foreach (var room in roomIdsEnum) {
+ tasks.Add(loadRoomData(room, rooms));
+ }
+
+ await Task.WhenAll(tasks);
+
+ async Task loadRoomData(GenericRoom room, List<GenericRoom> rooms) {
+ Log.Add($"Found room {room.RoomId}");
+ try {
+ await room.GetPowerLevelsAsync();
+ rooms.Add(room);
+ try {
+ roomAliasses[room] = (await room.GetCanonicalAliasAsync()).Alias;
+ }
+ catch { }
+
+ try {
+ roomNames[room] = await room.GetNameOrFallbackAsync();
+ }
+ catch { }
+ }
+ catch (MatrixException e) {
+ Log.Add($"Failed to get power levels for {room.RoomId}: {e.Message}");
+ }
+ }
+ }
+
+ public class Match {
+ public GenericRoom Room { get; set; }
+ public StateEventResponse Member { get; set; }
+ }
+
+}
\ No newline at end of file
diff --git a/MatrixUtils.Web/Pages/Tools/Moderation/UserTrace.razor b/MatrixUtils.Web/Pages/Tools/Moderation/UserTrace.razor
new file mode 100644
index 0000000..915f8dc
--- /dev/null
+++ b/MatrixUtils.Web/Pages/Tools/Moderation/UserTrace.razor
@@ -0,0 +1,194 @@
+@page "/Tools/Moderation/UserTrace"
+@using ArcaneLibs.Extensions
+@using LibMatrix.RoomTypes
+@using System.Collections.ObjectModel
+@using LibMatrix
+<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>
+
+<details>
+ <summary>Rooms to be searched (@rooms.Count)</summary>
+ @foreach (var room in rooms) {
+ <span>@room.RoomId</span>
+ <br/>
+ }
+</details>
+<br/>
+<LinkButton OnClick="Execute">Execute</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))</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();
+
+ protected override async Task OnInitializedAsync() {
+ log.CollectionChanged += (sender, args) => StateHasChanged();
+ var hs = await RMUStorage.GetCurrentSessionOrNavigate();
+ 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}");
+ }
+ }
+
+ //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();
+ log.Add($"Got {rooms.Count} rooms");
+
+ StateHasChanged();
+ Console.WriteLine("Rerendered!");
+ await base.OnInitializedAsync();
+ }
+
+ private async Task<string> Execute() {
+ 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);
+ }
+ }
+
+ 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;
+ }
+ }
+ }
+
+}
\ No newline at end of file
|