From c660b3e620de241c1158d08edaf0a99028364977 Mon Sep 17 00:00:00 2001 From: Rory& Date: Sat, 16 Aug 2025 00:17:33 +0200 Subject: Some cleanup, further room builder work --- LibMatrix/Helpers/RoomBuilder.cs | 37 +++++++++++++--- LibMatrix/Helpers/RoomUpgradeBuilder.cs | 30 +++++++++++-- .../Homeservers/AuthenticatedHomeserverGeneric.cs | 1 + .../Synapse/SynapseAdminApiClient.cs | 1 + LibMatrix/Homeservers/RemoteHomeServer.cs | 51 +++++++++++----------- LibMatrix/MxcUri.cs | 43 ------------------ LibMatrix/RoomTypes/GenericRoom.cs | 4 +- LibMatrix/StructuredData/MxcUri.cs | 43 ++++++++++++++++++ LibMatrix/StructuredData/UserId.cs | 27 ++++++++++++ 9 files changed, 158 insertions(+), 79 deletions(-) delete mode 100644 LibMatrix/MxcUri.cs create mode 100644 LibMatrix/StructuredData/MxcUri.cs create mode 100644 LibMatrix/StructuredData/UserId.cs (limited to 'LibMatrix') diff --git a/LibMatrix/Helpers/RoomBuilder.cs b/LibMatrix/Helpers/RoomBuilder.cs index 601f001..6dfb056 100644 --- a/LibMatrix/Helpers/RoomBuilder.cs +++ b/LibMatrix/Helpers/RoomBuilder.cs @@ -4,11 +4,13 @@ using LibMatrix.EventTypes.Spec.State.RoomInfo; using LibMatrix.Homeservers; using LibMatrix.Responses; using LibMatrix.RoomTypes; +using LibMatrix.StructuredData; namespace LibMatrix.Helpers; public class RoomBuilder { private static readonly string[] V12PlusRoomVersions = ["org.matrix.hydra.11", "12"]; + public bool SynapseAdminAutoAcceptLocalInvites { get; set; } public string? Type { get; set; } public string Version { get; set; } = "11"; public RoomNameEventContent Name { get; set; } = new(); @@ -130,6 +132,8 @@ public class RoomBuilder { AdditionalCreators.RemoveAll(string.IsNullOrWhiteSpace); if (V12PlusRoomVersions.Contains(Version) && AdditionalCreators is { Count: > 0 }) { crq.CreationContent.Add("additional_creators", AdditionalCreators); + foreach (var user in AdditionalCreators) + PowerLevels.Users?.Remove(user); } foreach (var kvp in AdditionalCreationContent) { @@ -150,6 +154,23 @@ public class RoomBuilder { private async Task SendInvites(GenericRoom room) { if (Invites.Count == 0) return; + if (SynapseAdminAutoAcceptLocalInvites && room.Homeserver is AuthenticatedHomeserverSynapse synapse) { + var localJoinTasks = Invites.Where(u => UserId.Parse(u.Key).ServerName == synapse.ServerName).Select(async entry => { + var user = entry.Key; + var reason = entry.Value; + try { + var uhs = await synapse.Admin.GetHomeserverForUserAsync(user, TimeSpan.FromHours(1)); + var userRoom = uhs.GetRoom(room.RoomId); + await userRoom.JoinAsync([uhs.ServerName], reason); + await uhs.Logout(); + } + catch (MatrixException e) { + Console.WriteLine("Failed to auto-accept invite for {0} in {1}: {2}", user, room.RoomId, e.Message); + } + }).ToList(); + await Task.WhenAll(localJoinTasks); + } + var inviteTasks = Invites.Select(async kvp => { try { await room.InviteUserAsync(kvp.Key, kvp.Value); @@ -163,11 +184,17 @@ public class RoomBuilder { } private async Task SetStatesAsync(GenericRoom room, List state) { - foreach (var ev in state) { - await (string.IsNullOrWhiteSpace(ev.StateKey) - ? room.SendStateEventAsync(ev.Type, ev.RawContent) - : room.SendStateEventAsync(ev.Type, ev.StateKey, ev.RawContent)); - } + // foreach (var ev in state) { + // await (string.IsNullOrWhiteSpace(ev.StateKey) + // ? room.SendStateEventAsync(ev.Type, ev.RawContent) + // : room.SendStateEventAsync(ev.Type, ev.StateKey, ev.RawContent)); + // } + + foreach (var group in state.Chunk(100)) + await room.BulkSendEventsAsync(group); + + // var tasks = state.Chunk(50).Select(room.BulkSendEventsAsync).ToList(); + // await Task.WhenAll(tasks); } private async Task SetBasicRoomInfoAsync(GenericRoom room) { diff --git a/LibMatrix/Helpers/RoomUpgradeBuilder.cs b/LibMatrix/Helpers/RoomUpgradeBuilder.cs index f9ce62b..8eaf9b8 100644 --- a/LibMatrix/Helpers/RoomUpgradeBuilder.cs +++ b/LibMatrix/Helpers/RoomUpgradeBuilder.cs @@ -1,11 +1,14 @@ using System.Diagnostics; +using System.Reflection; using System.Text.Json.Serialization; using ArcaneLibs; +using LibMatrix.EventTypes; using LibMatrix.EventTypes.Spec; using LibMatrix.EventTypes.Spec.State.Policy; using LibMatrix.EventTypes.Spec.State.RoomInfo; using LibMatrix.Homeservers; using LibMatrix.RoomTypes; +using LibMatrix.StructuredData; namespace LibMatrix.Helpers; @@ -15,11 +18,13 @@ public class RoomUpgradeBuilder : RoomBuilder { public bool CanUpgrade { get; private set; } public Dictionary AdditionalTombstoneContent { get; set; } = new(); + private List basePolicyTypes = []; + public async Task ImportAsync(GenericRoom OldRoom) { var sw = Stopwatch.StartNew(); var total = 0; - var basePolicyTypes = ClassCollector.ResolveFromAllAccessibleAssemblies().ToList(); + basePolicyTypes = ClassCollector.ResolveFromAllAccessibleAssemblies().ToList(); Console.WriteLine($"Found {basePolicyTypes.Count} policy types in {sw.ElapsedMilliseconds}ms"); CanUpgrade = ( (await OldRoom.GetPowerLevelsAsync())?.UserHasStatePermission(OldRoom.Homeserver.UserId, RoomTombstoneEventContent.EventId) @@ -39,7 +44,7 @@ public class RoomUpgradeBuilder : RoomBuilder { if (evt.StateKey == "") { if (evt.Type == RoomCreateEventContent.EventId) foreach (var (key, value) in evt.RawContent) { - if (key == "version") continue; + if (key is "room_version" or "creator") continue; if (key == "type") Type = value!.GetValue(); else AdditionalCreationContent[key] = value; @@ -80,8 +85,11 @@ public class RoomUpgradeBuilder : RoomBuilder { }); } else if (evt.Type == RoomMemberEventContent.EventId) { - if (UpgradeOptions.InviteMembers && evt.TypedContent is RoomMemberEventContent { Membership: "join" or "invite" } invitedMember) { - Invites.TryAdd(evt.StateKey!, invitedMember.Reason ?? "Room upgrade"); + if (evt.TypedContent is RoomMemberEventContent { Membership: "join" or "invite" } invitedMember) { + if (UpgradeOptions.InviteMembers) + Invites.TryAdd(evt.StateKey!, invitedMember.Reason ?? "Room upgrade"); + else if (UpgradeOptions.InviteLocalMembers && UserId.Parse(evt.StateKey!).ServerName == OldRoom.Homeserver.ServerName) + Invites.TryAdd(evt.StateKey!, invitedMember.Reason ?? "Room upgrade (local user)"); } else if (UpgradeOptions.MigrateBans && evt.TypedContent is RoomMemberEventContent { Membership: "ban" } bannedMember) Bans.TryAdd(evt.StateKey!, bannedMember.Reason); @@ -100,6 +108,19 @@ public class RoomUpgradeBuilder : RoomBuilder { } private StateEventResponse UpgradeUnstableValues(StateEventResponse evt) { + if (evt.IsLegacyType) { + var oldType = evt.Type; + evt.Type = evt.MappedType.GetCustomAttributes().FirstOrDefault(x => !x.Legacy)!.EventName; + Console.WriteLine($"Upgraded event type from {oldType} to {evt.Type} for event {evt.EventId}"); + } + + if (evt.MappedType.IsAssignableTo(typeof(PolicyRuleEventContent))) { + if (evt.RawContent["recommendation"]?.GetValue() == "org.matrix.mjolnir.ban") { + evt.RawContent["recommendation"] = "m.ban"; + Console.WriteLine($"Upgraded recommendation from 'org.matrix.mjolnir.ban' to 'm.ban' for event {evt.EventId}"); + } + } + return evt; } @@ -158,6 +179,7 @@ public class RoomUpgradeBuilder : RoomBuilder { public class RoomUpgradeOptions { public bool InviteMembers { get; set; } + public bool InviteLocalMembers { get; set; } public bool InvitePowerlevelUsers { get; set; } public bool MigrateBans { get; set; } public bool MigrateEmptyStateEvents { get; set; } diff --git a/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs b/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs index e2f52cd..47e7039 100644 --- a/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs +++ b/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs @@ -14,6 +14,7 @@ using LibMatrix.Homeservers.Extensions.NamedCaches; using LibMatrix.Responses; using LibMatrix.RoomTypes; using LibMatrix.Services; +using LibMatrix.StructuredData; using LibMatrix.Utilities; namespace LibMatrix.Homeservers; diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminApiClient.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminApiClient.cs index cee3d8d..4da0013 100644 --- a/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminApiClient.cs +++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminApiClient.cs @@ -16,6 +16,7 @@ using LibMatrix.Filters; using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Filters; using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses; using LibMatrix.Responses; +using LibMatrix.StructuredData; namespace LibMatrix.Homeservers.ImplementationDetails.Synapse; diff --git a/LibMatrix/Homeservers/RemoteHomeServer.cs b/LibMatrix/Homeservers/RemoteHomeServer.cs index f0b35f9..3e41075 100644 --- a/LibMatrix/Homeservers/RemoteHomeServer.cs +++ b/LibMatrix/Homeservers/RemoteHomeServer.cs @@ -3,6 +3,7 @@ using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Web; +using ArcaneLibs.Collections; using ArcaneLibs.Extensions; using LibMatrix.Extensions; using LibMatrix.Responses; @@ -28,7 +29,8 @@ public class RemoteHomeserver { Auth = new(this); } - private Dictionary _profileCache { get; set; } = new(); + // private Dictionary _profileCache { get; set; } = new(); + private SemaphoreCache _profileCache { get; set; } = new(); public string ServerNameOrUrl { get; } public string? Proxy { get; } @@ -40,27 +42,12 @@ public class RemoteHomeserver { public HomeserverResolverService.WellKnownUris WellKnownUris { get; set; } - public async Task GetProfileAsync(string mxid, bool useCache = false) { - if (mxid is null) throw new ArgumentNullException(nameof(mxid)); - if (useCache && _profileCache.TryGetValue(mxid, out var value)) { - if (value is SemaphoreSlim s) await s.WaitAsync(); - if (value is UserProfileResponse p) return p; - } - - _profileCache[mxid] = new SemaphoreSlim(1); - - var resp = await ClientHttpClient.GetAsync($"/_matrix/client/v3/profile/{HttpUtility.UrlEncode(mxid)}"); - var data = await resp.Content.ReadFromJsonAsync(); - if (!resp.IsSuccessStatusCode) Console.WriteLine("Profile: " + data); - _profileCache[mxid] = data ?? throw new InvalidOperationException($"Could not get profile for {mxid}"); - - return data; - } - // TODO: Do we need to support retrieving individual profile properties? Is there any use for that besides just getting the full profile? + public async Task GetProfileAsync(string mxid) => + await ClientHttpClient.GetFromJsonAsync($"/_matrix/client/v3/profile/{HttpUtility.UrlEncode(mxid)}"); public async Task GetClientVersionsAsync() { - var resp = await ClientHttpClient.GetAsync($"/_matrix/client/versions"); + var resp = await ClientHttpClient.GetAsync("/_matrix/client/versions"); var data = await resp.Content.ReadFromJsonAsync(); if (!resp.IsSuccessStatusCode) Console.WriteLine("ClientVersions: " + data); return data ?? throw new InvalidOperationException("ClientVersionsResponse is null"); @@ -74,13 +61,27 @@ public class RemoteHomeserver { return data ?? throw new InvalidOperationException($"Could not resolve alias {alias}"); } - public Task GetPublicRoomsAsync(int limit = 100, string? server = null, string? since = null) => - ClientHttpClient.GetFromJsonAsync(buildUriWithParams("/_matrix/client/v3/publicRooms", (nameof(limit), true, limit), - (nameof(server), !string.IsNullOrWhiteSpace(server), server), (nameof(since), !string.IsNullOrWhiteSpace(since), since))); + public Task GetPublicRoomsAsync(int limit = 100, string? server = null, string? since = null) { + var url = $"/_matrix/client/v3/publicRooms?limit={limit}"; + if (!string.IsNullOrWhiteSpace(server)) { + url += $"&server={server}"; + } + + if (!string.IsNullOrWhiteSpace(since)) { + url += $"&since={since}"; + } + + return ClientHttpClient.GetFromJsonAsync(url); + } - // TODO: move this somewhere else - private string buildUriWithParams(string url, params (string name, bool include, object? value)[] values) { - return url + "?" + string.Join("&", values.Where(x => x.include)); + public async IAsyncEnumerable EnumeratePublicRoomsAsync(int limit = int.MaxValue, string? server = null, string? since = null, int chunkSize = 100) { + PublicRoomDirectoryResult res; + do { + res = await GetPublicRoomsAsync(chunkSize, server, since); + yield return res; + if (res.NextBatch is null || res.NextBatch == since || res.Chunk.Count == 0) break; + since = res.NextBatch; + } while (limit > 0 && limit-- > 0); } #region Authentication diff --git a/LibMatrix/MxcUri.cs b/LibMatrix/MxcUri.cs deleted file mode 100644 index 875ae53..0000000 --- a/LibMatrix/MxcUri.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -namespace LibMatrix; - -public class MxcUri { - public required string ServerName { get; set; } - public required string MediaId { get; set; } - - public static MxcUri Parse([StringSyntax("Uri")] string mxcUri) { - if (!mxcUri.StartsWith("mxc://")) throw new ArgumentException("Matrix Content URIs must start with 'mxc://'", nameof(mxcUri)); - var parts = mxcUri[6..].Split('/'); - if (parts.Length != 2) throw new ArgumentException($"Invalid Matrix Content URI '{mxcUri}' passed! Matrix Content URIs must exist of only 2 parts!", nameof(mxcUri)); - return new MxcUri { - ServerName = parts[0], - MediaId = parts[1] - }; - } - - public static implicit operator MxcUri(string mxcUri) => Parse(mxcUri); - public static implicit operator string(MxcUri mxcUri) => $"mxc://{mxcUri.ServerName}/{mxcUri.MediaId}"; - public static implicit operator (string, string)(MxcUri mxcUri) => (mxcUri.ServerName, mxcUri.MediaId); - public static implicit operator MxcUri((string serverName, string mediaId) mxcUri) => (mxcUri.serverName, mxcUri.mediaId); - // public override string ToString() => $"mxc://{ServerName}/{MediaId}"; - - public string ToDownloadUri(string? baseUrl = null, string? filename = null, int? timeout = null) { - var uri = $"{baseUrl}/_matrix/client/v1/media/download/{ServerName}/{MediaId}"; - if (filename is not null) uri += $"/{filename}"; - if (timeout is not null) uri += $"?timeout={timeout}"; - return uri; - } - - public string ToLegacyDownloadUri(string? baseUrl = null, string? filename = null, int? timeout = null) { - var uri = $"{baseUrl}/_matrix/media/v3/download/{ServerName}/{MediaId}"; - if (filename is not null) uri += $"/{filename}"; - if (timeout is not null) uri += $"?timeout_ms={timeout}"; - return uri; - } - - public void Deconstruct(out string serverName, out string mediaId) { - serverName = ServerName; - mediaId = MediaId; - } -} \ No newline at end of file diff --git a/LibMatrix/RoomTypes/GenericRoom.cs b/LibMatrix/RoomTypes/GenericRoom.cs index 7d21d68..9753176 100644 --- a/LibMatrix/RoomTypes/GenericRoom.cs +++ b/LibMatrix/RoomTypes/GenericRoom.cs @@ -623,7 +623,7 @@ public class GenericRoom { } } - public async Task BulkSendEventsAsync(IEnumerable events) { + public async Task BulkSendEventsAsync(IEnumerable events) { if ((await Homeserver.GetCapabilitiesAsync()).Capabilities.BulkSendEvents?.Enabled == true) await Homeserver.ClientHttpClient.PostAsJsonAsync( $"/_matrix/client/unstable/gay.rory.bulk_send_events/rooms/{RoomId}/bulk_send_events?_libmatrix_txn_id={Guid.NewGuid()}", events); @@ -638,7 +638,7 @@ public class GenericRoom { } } - public async Task BulkSendEventsAsync(IAsyncEnumerable events) { + public async Task BulkSendEventsAsync(IAsyncEnumerable events) { if ((await Homeserver.GetCapabilitiesAsync()).Capabilities.BulkSendEvents?.Enabled == true) await Homeserver.ClientHttpClient.PostAsJsonAsync( $"/_matrix/client/unstable/gay.rory.bulk_send_events/rooms/{RoomId}/bulk_send_events?_libmatrix_txn_id={Guid.NewGuid()}", events); diff --git a/LibMatrix/StructuredData/MxcUri.cs b/LibMatrix/StructuredData/MxcUri.cs new file mode 100644 index 0000000..82a9677 --- /dev/null +++ b/LibMatrix/StructuredData/MxcUri.cs @@ -0,0 +1,43 @@ +using System.Diagnostics.CodeAnalysis; + +namespace LibMatrix.StructuredData; + +public class MxcUri { + public required string ServerName { get; set; } + public required string MediaId { get; set; } + + public static MxcUri Parse([StringSyntax("Uri")] string mxcUri) { + if (!mxcUri.StartsWith("mxc://")) throw new ArgumentException("Matrix Content URIs must start with 'mxc://'", nameof(mxcUri)); + var parts = mxcUri[6..].Split('/'); + if (parts.Length != 2) throw new ArgumentException($"Invalid Matrix Content URI '{mxcUri}' passed! Matrix Content URIs must exist of only 2 parts!", nameof(mxcUri)); + return new MxcUri { + ServerName = parts[0], + MediaId = parts[1] + }; + } + + public static implicit operator MxcUri(string mxcUri) => Parse(mxcUri); + public static implicit operator string(MxcUri mxcUri) => $"mxc://{mxcUri.ServerName}/{mxcUri.MediaId}"; + public static implicit operator (string, string)(MxcUri mxcUri) => (mxcUri.ServerName, mxcUri.MediaId); + public static implicit operator MxcUri((string serverName, string mediaId) mxcUri) => (mxcUri.serverName, mxcUri.mediaId); + // public override string ToString() => $"mxc://{ServerName}/{MediaId}"; + + public string ToDownloadUri(string? baseUrl = null, string? filename = null, int? timeout = null) { + var uri = $"{baseUrl}/_matrix/client/v1/media/download/{ServerName}/{MediaId}"; + if (filename is not null) uri += $"/{filename}"; + if (timeout is not null) uri += $"?timeout={timeout}"; + return uri; + } + + public string ToLegacyDownloadUri(string? baseUrl = null, string? filename = null, int? timeout = null) { + var uri = $"{baseUrl}/_matrix/media/v3/download/{ServerName}/{MediaId}"; + if (filename is not null) uri += $"/{filename}"; + if (timeout is not null) uri += $"?timeout_ms={timeout}"; + return uri; + } + + public void Deconstruct(out string serverName, out string mediaId) { + serverName = ServerName; + mediaId = MediaId; + } +} \ No newline at end of file diff --git a/LibMatrix/StructuredData/UserId.cs b/LibMatrix/StructuredData/UserId.cs new file mode 100644 index 0000000..02b2e91 --- /dev/null +++ b/LibMatrix/StructuredData/UserId.cs @@ -0,0 +1,27 @@ +namespace LibMatrix.StructuredData; + +public class UserId { + public required string ServerName { get; set; } + public required string LocalPart { get; set; } + + public static UserId Parse(string mxid) { + if (!mxid.StartsWith('@')) throw new ArgumentException("Matrix User IDs must start with '@'", nameof(mxid)); + var parts = mxid.Split(':', 2); + if (parts.Length != 2) throw new ArgumentException($"Invalid MXID '{mxid}' passed! MXIDs must exist of only 2 parts!", nameof(mxid)); + return new UserId { + LocalPart = parts[0][1..], + ServerName = parts[1] + }; + } + + public static implicit operator UserId(string mxid) => Parse(mxid); + public static implicit operator string(UserId mxid) => $"@{mxid.LocalPart}:{mxid.ServerName}"; + public static implicit operator (string, string)(UserId mxid) => (mxid.LocalPart, mxid.ServerName); + public static implicit operator UserId((string localPart, string serverName) mxid) => (mxid.localPart, mxid.serverName); + // public override string ToString() => $"mxc://{ServerName}/{MediaId}"; + + public void Deconstruct(out string serverName, out string localPart) { + serverName = ServerName; + localPart = LocalPart; + } +} \ No newline at end of file -- cgit 1.5.1