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.cs217
-rw-r--r--LibMatrix/RoomTypes/PolicyRoom.cs51
-rw-r--r--LibMatrix/RoomTypes/SpaceRoom.cs7
3 files changed, 203 insertions, 72 deletions
diff --git a/LibMatrix/RoomTypes/GenericRoom.cs b/LibMatrix/RoomTypes/GenericRoom.cs

index 349ccb5..fd4db4d 100644 --- a/LibMatrix/RoomTypes/GenericRoom.cs +++ b/LibMatrix/RoomTypes/GenericRoom.cs
@@ -1,5 +1,4 @@ using System.Collections.Frozen; -using System.Diagnostics; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes; @@ -8,13 +7,11 @@ using System.Web; using ArcaneLibs.Extensions; using LibMatrix.EventTypes; using LibMatrix.EventTypes.Spec; -using LibMatrix.EventTypes.Spec.State; using LibMatrix.EventTypes.Spec.State.RoomInfo; using LibMatrix.Filters; using LibMatrix.Helpers; using LibMatrix.Homeservers; -using LibMatrix.Services; -using Microsoft.Extensions.Logging.Abstractions; +using LibMatrix.Responses; namespace LibMatrix.RoomTypes; @@ -26,8 +23,6 @@ public class GenericRoom { throw new ArgumentException("Room ID cannot be null or whitespace", nameof(roomId)); Homeserver = homeserver; RoomId = roomId; - // if (GetType() != typeof(SpaceRoom)) - if (GetType() == typeof(GenericRoom)) AsSpace = new SpaceRoom(homeserver, RoomId); } public string RoomId { get; set; } @@ -106,7 +101,7 @@ public class GenericRoom { Console.WriteLine("WARNING: Homeserver does not support getting event ID from state events, falling back to sync"); var sh = new SyncHelper(Homeserver); var emptyFilter = new SyncFilter.EventFilter(types: [], limit: 1, senders: [], notTypes: ["*"]); - var emptyStateFilter = new SyncFilter.RoomFilter.StateFilter(types: [], limit: 1, senders: [], notTypes: ["*"], rooms:[]); + var emptyStateFilter = new SyncFilter.RoomFilter.StateFilter(types: [], limit: 1, senders: [], notTypes: ["*"], rooms: []); sh.Filter = new() { Presence = emptyFilter, AccountData = emptyFilter, @@ -121,10 +116,11 @@ public class GenericRoom { var sync = await sh.SyncAsync(); var state = sync.Rooms.Join[RoomId].State.Events; var stateEvent = state.FirstOrDefault(x => x.Type == type && x.StateKey == stateKey); - if (stateEvent is null) throw new LibMatrixException() { - ErrorCode = LibMatrixException.ErrorCodes.M_NOT_FOUND, - Error = "State event not found in sync response" - }; + if (stateEvent is null) + throw new LibMatrixException() { + ErrorCode = LibMatrixException.ErrorCodes.M_NOT_FOUND, + Error = "State event not found in sync response" + }; return stateEvent.EventId; } @@ -137,16 +133,17 @@ public class GenericRoom { return await GetStateEventAsync(type, stateKey); } catch (MatrixException e) { - if (e.ErrorCode == "M_NOT_FOUND") return default; + if (e.ErrorCode == "M_NOT_FOUND") return null; throw; } } - public async Task<MessagesResponse> GetMessagesAsync(string from = "", int? limit = null, string dir = "b", string filter = "") { + public async Task<MessagesResponse> GetMessagesAsync(string from = "", int? limit = null, string dir = "b", string? filter = "") { var url = $"/_matrix/client/v3/rooms/{RoomId}/messages?dir={dir}"; if (!string.IsNullOrWhiteSpace(from)) url += $"&from={from}"; if (limit is not null) url += $"&limit={limit}"; if (!string.IsNullOrWhiteSpace(filter)) url += $"&filter={filter}"; + var res = await Homeserver.ClientHttpClient.GetFromJsonAsync<MessagesResponse>(url); return res; } @@ -154,8 +151,8 @@ public class GenericRoom { /// <summary> /// Same as <see cref="GetMessagesAsync"/>, except keeps fetching more responses until the beginning of the room is found, or the target message limit is reached /// </summary> - public async IAsyncEnumerable<MessagesResponse> GetManyMessagesAsync(string from = "", int limit = 100, string dir = "b", string filter = "", bool includeState = true, - bool fixForward = false, int chunkSize = 100) { + public async IAsyncEnumerable<MessagesResponse> GetManyMessagesAsync(string from = "", int limit = int.MaxValue, string dir = "b", string filter = "", bool includeState = true, + bool fixForward = false, int chunkSize = 250) { if (dir == "f" && fixForward) { var concat = new List<MessagesResponse>(); while (true) { @@ -207,7 +204,7 @@ public class GenericRoom { public async Task<string?> GetNameAsync() => (await GetStateOrNullAsync<RoomNameEventContent>("m.room.name"))?.Name; - public async Task<RoomIdResponse> JoinAsync(string[]? homeservers = null, string? reason = null, bool checkIfAlreadyMember = true) { + public async Task<RoomIdResponse> JoinAsync(IEnumerable<string>? homeservers = null, string? reason = null, bool checkIfAlreadyMember = true) { if (checkIfAlreadyMember) try { var ser = await GetStateEventOrNullAsync(RoomMemberEventContent.EventId, Homeserver.UserId); @@ -216,68 +213,74 @@ public class GenericRoom { RoomId = RoomId }; } - catch { } //ignore + catch { + // ignored + } var joinUrl = $"/_matrix/client/v3/join/{HttpUtility.UrlEncode(RoomId)}"; - Console.WriteLine($"Calling {joinUrl} with {homeservers?.Length ?? 0} via's..."); - if (homeservers == null || homeservers.Length == 0) homeservers = new[] { RoomId.Split(':', 2)[1] }; - var fullJoinUrl = $"{joinUrl}?server_name=" + string.Join("&server_name=", homeservers); + + var materialisedHomeservers = homeservers as string[] ?? homeservers?.ToArray() ?? []; + if (!materialisedHomeservers.Any()) materialisedHomeservers = [RoomId.Split(':', 2)[1]]; + + Console.WriteLine($"Calling {joinUrl} with {materialisedHomeservers.Length} via(s)..."); + + var fullJoinUrl = $"{joinUrl}?server_name=" + string.Join("&server_name=", materialisedHomeservers); + var res = await Homeserver.ClientHttpClient.PostAsJsonAsync(fullJoinUrl, new { reason }); return await res.Content.ReadFromJsonAsync<RoomIdResponse>() ?? throw new Exception("Failed to join room?"); } - public async IAsyncEnumerable<StateEventResponse> GetMembersEnumerableAsync(bool joinedOnly = true) { - // var sw = Stopwatch.StartNew(); - var res = await Homeserver.ClientHttpClient.GetAsync($"/_matrix/client/v3/rooms/{RoomId}/members"); - // if (sw.ElapsedMilliseconds > 1000) - // Console.WriteLine($"Members call responded in {sw.GetElapsedAndRestart()}"); - // else sw.Restart(); - // var resText = await res.Content.ReadAsStringAsync(); - // Console.WriteLine($"Members call response read in {sw.GetElapsedAndRestart()}"); + public async IAsyncEnumerable<StateEventResponse> 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 }); - // if (sw.ElapsedMilliseconds > 100) - // Console.WriteLine($"Members call deserialised in {sw.GetElapsedAndRestart()}"); - // else sw.Restart(); - foreach (var resp in result.Chunk) { - if (resp?.Type != "m.room.member") continue; - if (joinedOnly && resp.RawContent?["membership"]?.GetValue<string>() != "join") continue; + + 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 (isMembershipSet && resp.RawContent?["membership"]?.GetValue<string>() != membership) continue; yield return resp; } - - // if (sw.ElapsedMilliseconds > 100) - // Console.WriteLine($"Members call iterated in {sw.GetElapsedAndRestart()}"); } - public async Task<FrozenSet<StateEventResponse>> GetMembersListAsync(bool joinedOnly = true) { - // var sw = Stopwatch.StartNew(); - var res = await Homeserver.ClientHttpClient.GetAsync($"/_matrix/client/v3/rooms/{RoomId}/members"); - // if (sw.ElapsedMilliseconds > 1000) - // Console.WriteLine($"Members call responded in {sw.GetElapsedAndRestart()}"); - // else sw.Restart(); - // var resText = await res.Content.ReadAsStringAsync(); - // Console.WriteLine($"Members call response read in {sw.GetElapsedAndRestart()}"); + public async Task<FrozenSet<StateEventResponse>> 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 }); - // if (sw.ElapsedMilliseconds > 100) - // Console.WriteLine($"Members call deserialised in {sw.GetElapsedAndRestart()}"); - // else sw.Restart(); + + if (result is null) throw new Exception("Failed to deserialise members response"); + var members = new List<StateEventResponse>(); - foreach (var resp in result.Chunk) { - if (resp?.Type != "m.room.member") continue; - if (joinedOnly && resp.RawContent?["membership"]?.GetValue<string>() != "join") continue; + foreach (var resp in result.Chunk ?? []) { + if (resp.Type != "m.room.member") continue; + if (isMembershipSet && resp.RawContent?["membership"]?.GetValue<string>() != membership) continue; members.Add(resp); } - // if (sw.ElapsedMilliseconds > 100) - // Console.WriteLine($"Members call iterated in {sw.GetElapsedAndRestart()}"); 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) => @@ -285,7 +288,7 @@ public class GenericRoom { public async Task<List<string>?> GetAliasesAsync() { var res = await GetStateAsync<RoomAliasEventContent>("m.room.aliases"); - return res.Aliases; + return res?.Aliases; } public Task<RoomCanonicalAliasEventContent?> GetCanonicalAliasAsync() => @@ -317,16 +320,17 @@ public class GenericRoom { public Task<RoomPowerLevelEventContent?> GetPowerLevelsAsync() => GetStateAsync<RoomPowerLevelEventContent>("m.room.power_levels"); - [Obsolete("This method will be merged into GetNameAsync() in the future.")] public async Task<string> GetNameOrFallbackAsync(int maxMemberNames = 2) { try { - return await GetNameAsync(); + var name = await GetNameAsync(); + if (!string.IsNullOrEmpty(name)) return name; + throw new(); } catch { try { var alias = await GetCanonicalAliasAsync(); - if (alias?.Alias is not null) return alias.Alias; - throw new Exception("No name or alias"); + if (!string.IsNullOrWhiteSpace(alias?.Alias)) return alias.Alias; + throw new Exception("No alias"); } catch { try { @@ -334,7 +338,8 @@ public class GenericRoom { var memberList = new List<string>(); var memberCount = 0; await foreach (var member in members) - memberList.Add(member.RawContent?["displayname"]?.GetValue<string>() ?? ""); + if (member.StateKey != Homeserver.UserId) + memberList.Add(member.RawContent?["displayname"]?.GetValue<string>() ?? ""); memberCount = memberList.Count; memberList.RemoveAll(string.IsNullOrWhiteSpace); memberList = memberList.OrderBy(x => x).ToList(); @@ -374,9 +379,9 @@ public class GenericRoom { await Homeserver.ClientHttpClient.PostAsJsonAsync($"/_matrix/client/v3/rooms/{RoomId}/ban", new UserIdAndReason { UserId = userId, Reason = reason }); - public async Task UnbanAsync(string userId) => + public async Task UnbanAsync(string userId, string? reason = null) => await Homeserver.ClientHttpClient.PostAsJsonAsync($"/_matrix/client/v3/rooms/{RoomId}/unban", - new UserIdAndReason { UserId = userId }); + 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) @@ -393,7 +398,7 @@ public class GenericRoom { .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}/{stateKey}", 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) { @@ -404,6 +409,15 @@ public class GenericRoom { 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() { @@ -429,6 +443,16 @@ public class GenericRoom { return await res.Content.ReadFromJsonAsync<T>(); } + public async Task<T?> GetRoomAccountDataOrNullAsync<T>(string key) { + try { + return await GetRoomAccountDataAsync<T>(key); + } + catch (MatrixException e) { + if (e.ErrorCode == "M_NOT_FOUND") return default; + throw; + } + } + public async Task SetRoomAccountDataAsync(string key, object data) { var res = await Homeserver.ClientHttpClient.PutAsJsonAsync($"/_matrix/client/v3/user/{Homeserver.UserId}/rooms/{RoomId}/account_data/{key}", data); if (!res.IsSuccessStatusCode) { @@ -437,13 +461,65 @@ public class GenericRoom { } } - public Task<StateEventResponse> GetEventAsync(string eventId) => - Homeserver.ClientHttpClient.GetFromJsonAsync<StateEventResponse>($"/_matrix/client/v3/rooms/{RoomId}/event/{eventId}"); + public Task<StateEventResponse> GetEventAsync(string eventId, bool includeUnredactedContent = false) => + Homeserver.ClientHttpClient.GetFromJsonAsync<StateEventResponse>($"/_matrix/client/v3/rooms/{RoomId}/event/{eventId}?fi.mau.msc2815.include_unredacted_content={includeUnredactedContent}"); - public async Task<EventIdResponse> RedactEventAsync(string eventToRedact, string reason) { + public async Task<EventIdResponse> RedactEventAsync(string eventToRedact, string? reason = null) { var data = new { reason }; - return (await (await Homeserver.ClientHttpClient.PutAsJsonAsync( - $"/_matrix/client/v3/rooms/{RoomId}/redact/{eventToRedact}/{Guid.NewGuid()}", data)).Content.ReadFromJsonAsync<EventIdResponse>())!; + var url = $"/_matrix/client/v3/rooms/{RoomId}/redact/{eventToRedact}/{Guid.NewGuid().ToString()}"; + while (true) { + try { + return (await (await Homeserver.ClientHttpClient.PutAsJsonAsync(url, data)).Content.ReadFromJsonAsync<EventIdResponse>())!; + } + catch (MatrixException e) { + if (e is { ErrorCode: MatrixException.ErrorCodes.M_FORBIDDEN }) throw; + throw; + } + } + } + +#endregion + +#region Ephemeral Events + + /// <summary> + /// This tells the server that the user is typing for the next N milliseconds where + /// N is the value specified in the timeout key. Alternatively, if typing is false, + /// it tells the server that the user has stopped typing. + /// </summary> + /// <param name="typing">Whether the user is typing or not.</param> + /// <param name="timeout">The length of time in milliseconds to mark this user as typing.</param> + public async Task SendTypingNotificationAsync(bool typing, int timeout = 30000) { + await Homeserver.ClientHttpClient.PutAsJsonAsync( + $"/_matrix/client/v3/rooms/{RoomId}/typing/{Homeserver.UserId}", new JsonObject { + ["timeout"] = typing ? timeout : null, + ["typing"] = typing + }, new JsonSerializerOptions { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }); + } + + /// <summary> + /// Updates the marker for the given receipt type to the event ID specified. + /// </summary> + /// <param name="eventId">The event ID to acknowledge up to.</param> + /// <param name="threadId"> + /// The root thread event’s ID (or main) for which thread this receipt is intended to be under. + /// If not specified, the read receipt is unthreaded (default). + /// </param> + /// <param name="isPrivate"> + /// If set to true, a receipt type of m.read.private is sent instead of m.read, which marks the + /// room as "read" only for the current user + /// </param> + public async Task SendReadReceiptAsync(string eventId, string? threadId = null, bool isPrivate = false) { + var request = new JsonObject(); + if (threadId != null) + request.Add("thread_id", threadId); + await Homeserver.ClientHttpClient.PostAsJsonAsync( + $"/_matrix/client/v3/rooms/{RoomId}/receipt/m.read{(isPrivate ? ".private" : "")}/{eventId}", request, + new JsonSerializerOptions { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }); } #endregion @@ -510,7 +586,7 @@ public class GenericRoom { var uri = new Uri(path, UriKind.Relative); if (dir == "b" || dir == "f") uri = uri.AddQuery("dir", dir); - else if(!string.IsNullOrWhiteSpace(dir)) throw new ArgumentException("Invalid direction", nameof(dir)); + else if (!string.IsNullOrWhiteSpace(dir)) throw new ArgumentException("Invalid direction", nameof(dir)); if (!string.IsNullOrEmpty(from)) uri = uri.AddQuery("from", from); if (chunkLimit is not null) uri = uri.AddQuery("limit", chunkLimit.Value.ToString()); if (recurse is not null) uri = uri.AddQuery("recurse", recurse.Value.ToString()); @@ -528,10 +604,11 @@ public class GenericRoom { } } - public readonly SpaceRoom AsSpace; + public SpaceRoom AsSpace() => new SpaceRoom(Homeserver, RoomId); + public PolicyRoom AsPolicyRoom() => new PolicyRoom(Homeserver, RoomId); } public class RoomIdResponse { [JsonPropertyName("room_id")] - public string RoomId { get; set; } = null!; + public string RoomId { get; set; } } \ No newline at end of file diff --git a/LibMatrix/RoomTypes/PolicyRoom.cs b/LibMatrix/RoomTypes/PolicyRoom.cs new file mode 100644
index 0000000..c6eec63 --- /dev/null +++ b/LibMatrix/RoomTypes/PolicyRoom.cs
@@ -0,0 +1,51 @@ +using System.Collections.Frozen; +using LibMatrix.EventTypes; +using LibMatrix.EventTypes.Spec.State.Policy; +using LibMatrix.Homeservers; + +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() { + var fullRoomState = GetFullStateAsync(); + await foreach (var eventResponse in fullRoomState) { + if (SpecPolicyEventTypes.Contains(eventResponse!.Type)) { + yield return eventResponse; + } + } + } + + public async IAsyncEnumerable<StateEventResponse> GetUserPoliciesAsync() { + var fullRoomState = GetPoliciesAsync(); + await foreach (var eventResponse in fullRoomState) { + if (UserPolicyEventTypes.Contains(eventResponse!.Type)) { + yield return eventResponse; + } + } + } + + public async IAsyncEnumerable<StateEventResponse> GetServerPoliciesAsync() { + var fullRoomState = GetPoliciesAsync(); + await foreach (var eventResponse in fullRoomState) { + if (ServerPolicyEventTypes.Contains(eventResponse!.Type)) { + yield return eventResponse; + } + } + } + + public async IAsyncEnumerable<StateEventResponse> GetRoomPoliciesAsync() { + var fullRoomState = GetPoliciesAsync(); + await foreach (var eventResponse in fullRoomState) { + if (RoomPolicyEventTypes.Contains(eventResponse!.Type)) { + yield return eventResponse; + } + } + } +} \ No newline at end of file diff --git a/LibMatrix/RoomTypes/SpaceRoom.cs b/LibMatrix/RoomTypes/SpaceRoom.cs
index b40ccc6..96abd77 100644 --- a/LibMatrix/RoomTypes/SpaceRoom.cs +++ b/LibMatrix/RoomTypes/SpaceRoom.cs
@@ -1,9 +1,12 @@ using ArcaneLibs.Extensions; using LibMatrix.Homeservers; +using LibMatrix.Responses; namespace LibMatrix.RoomTypes; public class SpaceRoom(AuthenticatedHomeserverGeneric homeserver, string roomId) : GenericRoom(homeserver, roomId) { + public const string TypeName = "m.space"; + public async IAsyncEnumerable<GenericRoom> GetChildrenAsync(bool includeRemoved = false) { // var rooms = new List<GenericRoom>(); var state = GetFullStateAsync(); @@ -15,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]; @@ -31,7 +34,7 @@ public class SpaceRoom(AuthenticatedHomeserverGeneric homeserver, string roomId) }); return resp; } - + public async Task<EventIdResponse> AddChildByIdAsync(string id) { return await AddChildAsync(Homeserver.GetRoom(id)); }