about summary refs log tree commit diff
path: root/LibMatrix/RoomTypes
diff options
context:
space:
mode:
Diffstat (limited to 'LibMatrix/RoomTypes')
-rw-r--r--LibMatrix/RoomTypes/GenericRoom.cs202
-rw-r--r--LibMatrix/RoomTypes/PolicyRoom.cs10
-rw-r--r--LibMatrix/RoomTypes/SpaceRoom.cs3
3 files changed, 177 insertions, 38 deletions
diff --git a/LibMatrix/RoomTypes/GenericRoom.cs b/LibMatrix/RoomTypes/GenericRoom.cs

index 93736a3..550eb58 100644 --- a/LibMatrix/RoomTypes/GenericRoom.cs +++ b/LibMatrix/RoomTypes/GenericRoom.cs
@@ -1,6 +1,5 @@ using System.Collections.Frozen; using System.Net.Http.Json; -using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; @@ -12,6 +11,7 @@ using LibMatrix.EventTypes.Spec.State.RoomInfo; using LibMatrix.Filters; using LibMatrix.Helpers; using LibMatrix.Homeservers; +using LibMatrix.Responses; namespace LibMatrix.RoomTypes; @@ -27,13 +27,13 @@ public class GenericRoom { public string RoomId { get; set; } - public async IAsyncEnumerable<StateEventResponse?> GetFullStateAsync() { - var result = Homeserver.ClientHttpClient.GetAsyncEnumerableFromJsonAsync<StateEventResponse>($"/_matrix/client/v3/rooms/{RoomId}/state"); + public async IAsyncEnumerable<MatrixEventResponse?> GetFullStateAsync() { + var result = Homeserver.ClientHttpClient.GetAsyncEnumerableFromJsonAsync<MatrixEventResponse>($"/_matrix/client/v3/rooms/{RoomId}/state"); await foreach (var resp in result) yield return resp; } - public Task<List<StateEventResponse>> GetFullStateAsListAsync() => - Homeserver.ClientHttpClient.GetFromJsonAsync<List<StateEventResponse>>($"/_matrix/client/v3/rooms/{RoomId}/state"); + public Task<List<MatrixEventResponse>> GetFullStateAsListAsync() => + Homeserver.ClientHttpClient.GetFromJsonAsync<List<MatrixEventResponse>>($"/_matrix/client/v3/rooms/{RoomId}/state"); public async Task<T?> GetStateAsync<T>(string type, string stateKey = "") { if (string.IsNullOrEmpty(type)) throw new ArgumentNullException(nameof(type), "Event type must be specified"); @@ -63,20 +63,20 @@ public class GenericRoom { } } - public async Task<StateEventResponse> GetStateEventAsync(string type, string stateKey = "") { + public async Task<MatrixEventResponse> GetStateEventAsync(string type, string stateKey = "") { if (string.IsNullOrEmpty(type)) throw new ArgumentNullException(nameof(type), "Event type must be specified"); var url = $"/_matrix/client/v3/rooms/{RoomId}/state/{type}"; if (!string.IsNullOrEmpty(stateKey)) url += $"/{stateKey}"; url += "?format=event"; try { var resp = await Homeserver.ClientHttpClient.GetFromJsonAsync<JsonObject>(url); - if (resp["type"]?.GetValue<string>() != type) + if (resp["type"]?.GetValue<string>() != type || resp["state_key"]?.GetValue<string>() != stateKey) throw new LibMatrixException() { Error = "Homeserver returned event type does not match requested type, or server does not support passing `format`.", ErrorCode = LibMatrixException.ErrorCodes.M_UNSUPPORTED }; // throw new InvalidDataException("Returned event type does not match requested type, or server does not support passing `format`."); - return resp.Deserialize<StateEventResponse>(); + return resp.Deserialize<MatrixEventResponse>(); } catch (MatrixException e) { // if (e is not { ErrorCodode: "M_NOT_FOUND" }) { @@ -128,7 +128,7 @@ public class GenericRoom { } } - public async Task<StateEventResponse?> GetStateEventOrNullAsync(string type, string stateKey = "") { + public async Task<MatrixEventResponse?> GetStateEventOrNullAsync(string type, string stateKey = "") { try { return await GetStateEventAsync(type, stateKey); } @@ -220,7 +220,16 @@ public class GenericRoom { var joinUrl = $"/_matrix/client/v3/join/{HttpUtility.UrlEncode(RoomId)}"; var materialisedHomeservers = homeservers as string[] ?? homeservers?.ToArray() ?? []; - if (!materialisedHomeservers.Any()) materialisedHomeservers = [RoomId.Split(':', 2)[1]]; + if (!materialisedHomeservers.Any()) + if (RoomId.Contains(':')) + materialisedHomeservers = [Homeserver.ServerName, RoomId.Split(':')[1]]; + // v12+ room IDs: !<hash> + else { + materialisedHomeservers = [Homeserver.ServerName]; + foreach (var room in await Homeserver.GetJoinedRooms()) { + materialisedHomeservers.Add(await room.GetOriginHomeserverAsync()); + } + } Console.WriteLine($"Calling {joinUrl} with {materialisedHomeservers.Length} via(s)..."); @@ -232,39 +241,55 @@ public class GenericRoom { return await res.Content.ReadFromJsonAsync<RoomIdResponse>() ?? throw new Exception("Failed to join room?"); } - public async IAsyncEnumerable<StateEventResponse> GetMembersEnumerableAsync(bool joinedOnly = true) { - var res = await Homeserver.ClientHttpClient.GetAsync($"/_matrix/client/v3/rooms/{RoomId}/members"); - var result = await JsonSerializer.DeserializeAsync<ChunkedStateEventResponse>(await res.Content.ReadAsStreamAsync(), new JsonSerializerOptions() { - TypeInfoResolver = ChunkedStateEventResponseSerializerContext.Default + public async IAsyncEnumerable<MatrixEventResponse> GetMembersEnumerableAsync(string? membership = null) { + var url = $"/_matrix/client/v3/rooms/{RoomId}/members"; + var isMembershipSet = !string.IsNullOrWhiteSpace(membership); + if (isMembershipSet) url += $"?membership={membership}"; + var res = await Homeserver.ClientHttpClient.GetAsync(url); + var result = await JsonSerializer.DeserializeAsync<ChunkedMatrixEventResponse>(await res.Content.ReadAsStreamAsync(), new JsonSerializerOptions() { + TypeInfoResolver = ChunkedMatrixEventResponseSerializerContext.Default }); if (result is null) throw new Exception("Failed to deserialise members response"); foreach (var resp in result.Chunk ?? []) { if (resp.Type != "m.room.member") continue; - if (joinedOnly && resp.RawContent?["membership"]?.GetValue<string>() != "join") continue; + if (isMembershipSet && resp.RawContent?["membership"]?.GetValue<string>() != membership) continue; yield return resp; } } - public async Task<FrozenSet<StateEventResponse>> GetMembersListAsync(bool joinedOnly = true) { - var res = await Homeserver.ClientHttpClient.GetAsync($"/_matrix/client/v3/rooms/{RoomId}/members"); - var result = await JsonSerializer.DeserializeAsync<ChunkedStateEventResponse>(await res.Content.ReadAsStreamAsync(), new JsonSerializerOptions() { - TypeInfoResolver = ChunkedStateEventResponseSerializerContext.Default + public async Task<FrozenSet<MatrixEventResponse>> GetMembersListAsync(string? membership = null) { + var url = $"/_matrix/client/v3/rooms/{RoomId}/members"; + var isMembershipSet = !string.IsNullOrWhiteSpace(membership); + if (isMembershipSet) url += $"?membership={membership}"; + var res = await Homeserver.ClientHttpClient.GetAsync(url); + var result = await JsonSerializer.DeserializeAsync<ChunkedMatrixEventResponse>(await res.Content.ReadAsStreamAsync(), new JsonSerializerOptions() { + TypeInfoResolver = ChunkedMatrixEventResponseSerializerContext.Default }); if (result is null) throw new Exception("Failed to deserialise members response"); - var members = new List<StateEventResponse>(); + var members = new List<MatrixEventResponse>(); foreach (var resp in result.Chunk ?? []) { if (resp.Type != "m.room.member") continue; - if (joinedOnly && resp.RawContent?["membership"]?.GetValue<string>() != "join") continue; + if (isMembershipSet && resp.RawContent?["membership"]?.GetValue<string>() != membership) continue; members.Add(resp); } return members.ToFrozenSet(); } + public async IAsyncEnumerable<string> GetMemberIdsEnumerableAsync(string? membership = null) { + await foreach (var evt in GetMembersEnumerableAsync(membership)) + yield return evt.StateKey!; + } + + public async Task<FrozenSet<string>> GetMemberIdsListAsync(string? membership = null) { + var members = await GetMembersListAsync(membership); + return members.Select(x => x.StateKey!).ToFrozenSet(); + } + #region Utility shortcuts public Task<EventIdResponse> SendMessageEventAsync(RoomMessageEventContent content) => @@ -296,6 +321,9 @@ public class GenericRoom { public Task<RoomCreateEventContent?> GetCreateEventAsync() => GetStateAsync<RoomCreateEventContent>("m.room.create"); + public Task<RoomPolicyServerEventContent?> GetPolicyServerAsync() => + GetStateAsync<RoomPolicyServerEventContent>(RoomPolicyServerEventContent.EventId); + public async Task<string?> GetRoomType() { var res = await GetStateAsync<RoomCreateEventContent>("m.room.create"); return res.Type; @@ -368,7 +396,7 @@ public class GenericRoom { new UserIdAndReason { UserId = userId, Reason = reason }); public async Task InviteUserAsync(string userId, string? reason = null, bool skipExisting = true) { - if (skipExisting && await GetStateOrNullAsync<RoomMemberEventContent>("m.room.member", userId) is not null) + if (skipExisting && await GetStateOrNullAsync<RoomMemberEventContent>("m.room.member", userId) is { Membership: "ban" or "join" }) return; await Homeserver.ClientHttpClient.PostAsJsonAsync($"/_matrix/client/v3/rooms/{RoomId}/invite", new UserIdAndReason(userId, reason)); } @@ -378,12 +406,12 @@ public class GenericRoom { #region Events public async Task<EventIdResponse?> SendStateEventAsync(string eventType, object content) => - await (await Homeserver.ClientHttpClient.PutAsJsonAsync($"/_matrix/client/v3/rooms/{RoomId}/state/{eventType}", content)) + await (await Homeserver.ClientHttpClient.PutAsJsonAsync($"/_matrix/client/v3/rooms/{RoomId}/state/{eventType.UrlEncode()}", content)) .Content.ReadFromJsonAsync<EventIdResponse>(); - public async Task<EventIdResponse?> SendStateEventAsync(string eventType, string stateKey, object content) => - await (await Homeserver.ClientHttpClient.PutAsJsonAsync($"/_matrix/client/v3/rooms/{RoomId}/state/{eventType.UrlEncode()}/{stateKey.UrlEncode()}", content)) - .Content.ReadFromJsonAsync<EventIdResponse>(); + public async Task<EventIdResponse> SendStateEventAsync(string eventType, string stateKey, object content) => + (await (await Homeserver.ClientHttpClient.PutAsJsonAsync($"/_matrix/client/v3/rooms/{RoomId}/state/{eventType.UrlEncode()}/{stateKey.UrlEncode()}", content)) + .Content.ReadFromJsonAsync<EventIdResponse>())!; public async Task<EventIdResponse> SendTimelineEventAsync(string eventType, TimelineEventContent content) { var res = await Homeserver.ClientHttpClient.PutAsJsonAsync( @@ -393,6 +421,23 @@ public class GenericRoom { return await res.Content.ReadFromJsonAsync<EventIdResponse>() ?? throw new Exception("Failed to send event"); } + public async Task<EventIdResponse> SendRawTimelineEventAsync(string eventType, JsonObject content) { + var res = await Homeserver.ClientHttpClient.PutAsJsonAsync( + $"/_matrix/client/v3/rooms/{RoomId}/send/{eventType}/" + Guid.NewGuid(), content, new JsonSerializerOptions { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }); + return await res.Content.ReadFromJsonAsync<EventIdResponse>() ?? throw new Exception("Failed to send event"); + } + + public async Task<EventIdResponse> SendReactionAsync(string eventId, string key) => + await SendTimelineEventAsync("m.reaction", new RoomMessageReactionEventContent() { + RelatesTo = new() { + RelationType = "m.annotation", + EventId = eventId, + Key = key + } + }); + public async Task<EventIdResponse?> SendFileAsync(string fileName, Stream fileStream, string messageType = "m.file", string contentType = "application/octet-stream") { var url = await Homeserver.UploadFile(fileName, fileStream); var content = new RoomMessageEventContent() { @@ -436,8 +481,10 @@ public class GenericRoom { } } - public Task<StateEventResponse> GetEventAsync(string eventId) => - Homeserver.ClientHttpClient.GetFromJsonAsync<StateEventResponse>($"/_matrix/client/v3/rooms/{RoomId}/event/{eventId}"); + public Task<MatrixEventResponse> GetEventAsync(string eventId, bool includeUnredactedContent = false) => + Homeserver.ClientHttpClient.GetFromJsonAsync<MatrixEventResponse>( + // .ToLower() on boolean here because this query param specifically on synapse is checked as a string rather than a boolean + $"/_matrix/client/v3/rooms/{RoomId}/event/{eventId}?fi.mau.msc2815.include_unredacted_content={includeUnredactedContent.ToString().ToLower()}"); public async Task<EventIdResponse> RedactEventAsync(string eventToRedact, string? reason = null) { var data = new { reason }; @@ -553,7 +600,7 @@ public class GenericRoom { #endregion - public async IAsyncEnumerable<StateEventResponse> GetRelatedEventsAsync(string eventId, string? relationType = null, string? eventType = null, string? dir = "f", + public async IAsyncEnumerable<MatrixEventResponse> GetRelatedEventsAsync(string eventId, string? relationType = null, string? eventType = null, string? dir = "f", string? from = null, int? chunkLimit = 100, bool? recurse = null, string? to = null) { var path = $"/_matrix/client/v1/rooms/{RoomId}/relations/{HttpUtility.UrlEncode(eventId)}"; if (!string.IsNullOrEmpty(relationType)) path += $"/{relationType}"; @@ -568,22 +615,113 @@ public class GenericRoom { if (!string.IsNullOrEmpty(to)) uri = uri.AddQuery("to", to); // Console.WriteLine($"Getting related events from {uri}"); - var result = await Homeserver.ClientHttpClient.GetFromJsonAsync<RecursedBatchedChunkedStateEventResponse>(uri.ToString()); + var result = await Homeserver.ClientHttpClient.GetFromJsonAsync<RecursedBatchedChunkedMatrixEventResponse>(uri.ToString()); while (result!.Chunk.Count > 0) { foreach (var resp in result.Chunk) { yield return resp; } if (result.NextBatch is null) break; - result = await Homeserver.ClientHttpClient.GetFromJsonAsync<RecursedBatchedChunkedStateEventResponse>(uri.AddQuery("from", result.NextBatch).ToString()); + result = await Homeserver.ClientHttpClient.GetFromJsonAsync<RecursedBatchedChunkedMatrixEventResponse>(uri.AddQuery("from", result.NextBatch).ToString()); + } + } + + public async Task BulkSendEventsAsync(IEnumerable<MatrixEvent> events, int? forceSyncInterval = null) { + if ((await Homeserver.GetCapabilitiesAsync()).Capabilities.BulkSendEvents?.Enabled == true) { + var uri = $"/_matrix/client/unstable/gay.rory.bulk_send_events/rooms/{RoomId}/bulk_send_events?_libmatrix_txn_id={Guid.NewGuid()}"; + if (forceSyncInterval is not null) uri += $"&force_sync_interval={forceSyncInterval}"; + await Homeserver.ClientHttpClient.PostAsJsonAsync(uri, events); + } + else { + Console.WriteLine("Homeserver does not support bulk sending events, falling back to individual sends."); + foreach (var evt in events) + await ( + evt.StateKey == null + ? SendRawTimelineEventAsync(evt.Type, evt.RawContent!) + : SendStateEventAsync(evt.Type, evt.StateKey, evt.RawContent) + ); + } + } + + public async Task BulkSendEventsAsync(IAsyncEnumerable<MatrixEvent> events, int? forceSyncInterval = null) { + if ((await Homeserver.GetCapabilitiesAsync()).Capabilities.BulkSendEvents?.Enabled == true) { + var uri = $"/_matrix/client/unstable/gay.rory.bulk_send_events/rooms/{RoomId}/bulk_send_events?_libmatrix_txn_id={Guid.NewGuid()}"; + if (forceSyncInterval is not null) uri += $"&force_sync_interval={forceSyncInterval}"; + await Homeserver.ClientHttpClient.PostAsJsonAsync(uri, events); + } + else { + Console.WriteLine("Homeserver does not support bulk sending events, falling back to individual sends."); + await foreach (var evt in events) + await ( + evt.StateKey == null + ? SendRawTimelineEventAsync(evt.Type, evt.RawContent!) + : SendStateEventAsync(evt.Type, evt.StateKey, evt.RawContent) + ); } } public SpaceRoom AsSpace() => new SpaceRoom(Homeserver, RoomId); public PolicyRoom AsPolicyRoom() => new PolicyRoom(Homeserver, RoomId); + + /// <summary> + /// Unsafe: does not actually check if the room is v12, it just checks the room ID format as an estimation. + /// </summary> + public bool IsV12PlusRoomId => !RoomId.Contains(':'); + + /// <summary> + /// Gets the list of room creators for this room. + /// </summary> + /// <returns>A list of size 1 for v11 rooms and older, all creators for v12+</returns> + public async Task<List<string>> GetRoomCreatorsAsync() { + MatrixEventResponse createEvent; + if (IsV12PlusRoomId) { + createEvent = await GetEventAsync('$' + RoomId[1..]); + } + else { + createEvent = await GetStateEventAsync("m.room.create"); + } + + List<string> creators = [createEvent.Sender ?? throw new InvalidDataException("Create event has no sender")]; + + if (IsV12PlusRoomId && createEvent.TypedContent is RoomCreateEventContent { AdditionalCreators: { Count: > 0 } additionalCreators }) { + creators.AddRange(additionalCreators); + } + + return creators; + } + + public async Task<string> GetOriginHomeserverAsync() { + // pre-v12 room ID + if (RoomId.Contains(':')) { + var parts = RoomId.Split(':', 2); + if (parts.Length == 2) return parts[1]; + } + + // v12 room ID/fallback + var creators = await GetRoomCreatorsAsync(); + if (creators.Count == 0) { + throw new InvalidDataException("Room has no creators, cannot determine origin homeserver"); + } + + return creators[0].Split(':', 2)[1]; + } + + public async Task<List<string>> GetHomeserversInRoom() => (await GetMemberIdsListAsync("join")).Select(x => x.Split(':', 2)[1]).Distinct().ToList(); + + public async Task<bool> IsJoinedAsync() { + try { + var member = await GetStateOrNullAsync<RoomMemberEventContent>(RoomMemberEventContent.EventId, Homeserver.UserId); + return member?.Membership == "join"; + } + catch (MatrixException e) { + if (e.ErrorCode == "M_NOT_FOUND") return false; + if (e.ErrorCode == "M_FORBIDDEN") return false; + throw; + } + } } public class RoomIdResponse { [JsonPropertyName("room_id")] public string RoomId { get; set; } -} +} \ No newline at end of file diff --git a/LibMatrix/RoomTypes/PolicyRoom.cs b/LibMatrix/RoomTypes/PolicyRoom.cs
index c6eec63..e4fa6ae 100644 --- a/LibMatrix/RoomTypes/PolicyRoom.cs +++ b/LibMatrix/RoomTypes/PolicyRoom.cs
@@ -7,13 +7,13 @@ namespace LibMatrix.RoomTypes; public class PolicyRoom(AuthenticatedHomeserverGeneric homeserver, string roomId) : GenericRoom(homeserver, roomId) { public const string TypeName = "support.feline.policy.lists.msc.v1"; - + public static readonly FrozenSet<string> UserPolicyEventTypes = EventContent.GetMatchingEventTypes<UserPolicyRuleEventContent>().ToFrozenSet(); public static readonly FrozenSet<string> ServerPolicyEventTypes = EventContent.GetMatchingEventTypes<ServerPolicyRuleEventContent>().ToFrozenSet(); public static readonly FrozenSet<string> RoomPolicyEventTypes = EventContent.GetMatchingEventTypes<RoomPolicyRuleEventContent>().ToFrozenSet(); public static readonly FrozenSet<string> SpecPolicyEventTypes = [..UserPolicyEventTypes, ..ServerPolicyEventTypes, ..RoomPolicyEventTypes]; - public async IAsyncEnumerable<StateEventResponse> GetPoliciesAsync() { + public async IAsyncEnumerable<MatrixEventResponse> GetPoliciesAsync() { var fullRoomState = GetFullStateAsync(); await foreach (var eventResponse in fullRoomState) { if (SpecPolicyEventTypes.Contains(eventResponse!.Type)) { @@ -22,7 +22,7 @@ public class PolicyRoom(AuthenticatedHomeserverGeneric homeserver, string roomId } } - public async IAsyncEnumerable<StateEventResponse> GetUserPoliciesAsync() { + public async IAsyncEnumerable<MatrixEventResponse> GetUserPoliciesAsync() { var fullRoomState = GetPoliciesAsync(); await foreach (var eventResponse in fullRoomState) { if (UserPolicyEventTypes.Contains(eventResponse!.Type)) { @@ -31,7 +31,7 @@ public class PolicyRoom(AuthenticatedHomeserverGeneric homeserver, string roomId } } - public async IAsyncEnumerable<StateEventResponse> GetServerPoliciesAsync() { + public async IAsyncEnumerable<MatrixEventResponse> GetServerPoliciesAsync() { var fullRoomState = GetPoliciesAsync(); await foreach (var eventResponse in fullRoomState) { if (ServerPolicyEventTypes.Contains(eventResponse!.Type)) { @@ -40,7 +40,7 @@ public class PolicyRoom(AuthenticatedHomeserverGeneric homeserver, string roomId } } - public async IAsyncEnumerable<StateEventResponse> GetRoomPoliciesAsync() { + public async IAsyncEnumerable<MatrixEventResponse> GetRoomPoliciesAsync() { var fullRoomState = GetPoliciesAsync(); await foreach (var eventResponse in fullRoomState) { if (RoomPolicyEventTypes.Contains(eventResponse!.Type)) { diff --git a/LibMatrix/RoomTypes/SpaceRoom.cs b/LibMatrix/RoomTypes/SpaceRoom.cs
index 4563ed3..96abd77 100644 --- a/LibMatrix/RoomTypes/SpaceRoom.cs +++ b/LibMatrix/RoomTypes/SpaceRoom.cs
@@ -1,5 +1,6 @@ using ArcaneLibs.Extensions; using LibMatrix.Homeservers; +using LibMatrix.Responses; namespace LibMatrix.RoomTypes; @@ -17,7 +18,7 @@ public class SpaceRoom(AuthenticatedHomeserverGeneric homeserver, string roomId) } public async Task<EventIdResponse> AddChildAsync(GenericRoom room) { - var members = room.GetMembersEnumerableAsync(true); + var members = room.GetMembersEnumerableAsync("join"); Dictionary<string, int> memberCountByHs = new(); await foreach (var member in members) { var server = member.StateKey.Split(':')[1];