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 {
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 0c74be5..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;
|