about summary refs log tree commit diff
path: root/LibMatrix/RoomTypes/GenericRoom.cs
diff options
context:
space:
mode:
Diffstat (limited to 'LibMatrix/RoomTypes/GenericRoom.cs')
-rw-r--r--LibMatrix/RoomTypes/GenericRoom.cs166
1 files changed, 138 insertions, 28 deletions
diff --git a/LibMatrix/RoomTypes/GenericRoom.cs b/LibMatrix/RoomTypes/GenericRoom.cs

index bc1bc90..6d9a499 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,13 +241,13 @@ public class GenericRoom { return await res.Content.ReadFromJsonAsync<RoomIdResponse>() ?? throw new Exception("Failed to join room?"); } - public async IAsyncEnumerable<StateEventResponse> GetMembersEnumerableAsync(string? membership = null) { + 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<ChunkedStateEventResponse>(await res.Content.ReadAsStreamAsync(), new JsonSerializerOptions() { - TypeInfoResolver = ChunkedStateEventResponseSerializerContext.Default + 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"); @@ -250,18 +259,18 @@ public class GenericRoom { } } - public async Task<FrozenSet<StateEventResponse>> GetMembersListAsync(string? membership = null) { + 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<ChunkedStateEventResponse>(await res.Content.ReadAsStreamAsync(), new JsonSerializerOptions() { - TypeInfoResolver = ChunkedStateEventResponseSerializerContext.Default + 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 (isMembershipSet && resp.RawContent?["membership"]?.GetValue<string>() != membership) continue; @@ -275,7 +284,7 @@ public class GenericRoom { 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(); @@ -384,7 +393,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 not { Membership: "leave" or "ban" or "join" }) return; await Homeserver.ClientHttpClient.PostAsJsonAsync($"/_matrix/client/v3/rooms/{RoomId}/invite", new UserIdAndReason(userId, reason)); } @@ -394,12 +403,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( @@ -409,6 +418,14 @@ 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() { @@ -461,8 +478,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 }; @@ -578,7 +597,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}"; @@ -593,19 +612,110 @@ 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 {