diff --git a/LibMatrix/Abstractions/VersionedKeyId.cs b/LibMatrix/Abstractions/VersionedKeyId.cs
new file mode 100644
index 0000000..0a6d651
--- /dev/null
+++ b/LibMatrix/Abstractions/VersionedKeyId.cs
@@ -0,0 +1,24 @@
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+
+namespace LibMatrix.Abstractions;
+
+[DebuggerDisplay("{Algorithm}:{KeyId}")]
+[SuppressMessage("ReSharper", "NonReadonlyMemberInGetHashCode")]
+public class VersionedKeyId {
+ public required string Algorithm { get; set; }
+ public required string KeyId { get; set; }
+
+ public static implicit operator VersionedKeyId(string key) {
+ var parts = key.Split(':', 2);
+ if (parts.Length != 2) throw new ArgumentException("Invalid key format. Expected 'algorithm:keyId'.", nameof(key));
+ return new VersionedKeyId { Algorithm = parts[0], KeyId = parts[1] };
+ }
+
+ public static implicit operator string(VersionedKeyId key) => $"{key.Algorithm}:{key.KeyId}";
+ public static implicit operator (string, string)(VersionedKeyId key) => (key.Algorithm, key.KeyId);
+ public static implicit operator VersionedKeyId((string algorithm, string keyId) key) => (key.algorithm, key.keyId);
+
+ public override bool Equals(object? obj) => obj is VersionedKeyId other && Algorithm == other.Algorithm && KeyId == other.KeyId;
+ public override int GetHashCode() => HashCode.Combine(Algorithm, KeyId);
+}
\ No newline at end of file
diff --git a/LibMatrix/Abstractions/VersionedPublicKey.cs b/LibMatrix/Abstractions/VersionedPublicKey.cs
new file mode 100644
index 0000000..ad6747d
--- /dev/null
+++ b/LibMatrix/Abstractions/VersionedPublicKey.cs
@@ -0,0 +1,16 @@
+namespace LibMatrix.Abstractions;
+
+public class VersionedPublicKey {
+ public required VersionedKeyId KeyId { get; set; }
+ public required string PublicKey { get; set; }
+}
+
+public class VersionedPrivateKey : VersionedPublicKey {
+ public required string PrivateKey { get; set; }
+}
+public class VersionedHomeserverPublicKey : VersionedPublicKey {
+ public required string ServerName { get; set; }
+}
+public class VersionedHomeserverPrivateKey : VersionedPrivateKey {
+ public required string ServerName { get; set; }
+}
\ No newline at end of file
diff --git a/LibMatrix/Extensions/CanonicalJsonSerializer.cs b/LibMatrix/Extensions/CanonicalJsonSerializer.cs
index 55a4b1a..ae535aa 100644
--- a/LibMatrix/Extensions/CanonicalJsonSerializer.cs
+++ b/LibMatrix/Extensions/CanonicalJsonSerializer.cs
@@ -1,6 +1,7 @@
using System.Collections.Frozen;
using System.Reflection;
using System.Text.Json;
+using System.Text.Json.Nodes;
using System.Text.Json.Serialization.Metadata;
using ArcaneLibs.Extensions;
@@ -57,6 +58,14 @@ public static class CanonicalJsonSerializer {
// public static String Serialize<TValue>(TValue value, JsonTypeInfo<TValue> jsonTypeInfo) => JsonSerializer.Serialize(value, jsonTypeInfo, _options);
// public static String Serialize(Object value, JsonTypeInfo jsonTypeInfo)
+ public static byte[] SerializeToUtf8Bytes<T>(T value, JsonSerializerOptions? options = null) {
+ var newOptions = MergeOptions(null);
+ return JsonSerializer.SerializeToNode(value, options) // We want to allow passing custom converters for eg. double/float -> string here...
+ .SortProperties()!
+ .CanonicalizeNumbers()!
+ .ToJsonString(newOptions).AsBytes().ToArray();
+ }
+
#endregion
// ReSharper disable once UnusedType.Local
diff --git a/LibMatrix/Extensions/EnumerableExtensions.cs b/LibMatrix/Extensions/EnumerableExtensions.cs
index 4dcf26e..88e79f0 100644
--- a/LibMatrix/Extensions/EnumerableExtensions.cs
+++ b/LibMatrix/Extensions/EnumerableExtensions.cs
@@ -4,7 +4,7 @@ using System.Collections.Immutable;
namespace LibMatrix.Extensions;
public static class EnumerableExtensions {
- public static void MergeStateEventLists(this IList<StateEvent> oldState, IList<StateEvent> newState) {
+ public static void MergeStateEventLists(this IList<MatrixEvent> oldState, IList<MatrixEvent> newState) {
// foreach (var stateEvent in newState) {
// var old = oldState.FirstOrDefault(x => x.Type == stateEvent.Type && x.StateKey == stateEvent.StateKey);
// if (old is null) {
@@ -27,7 +27,7 @@ public static class EnumerableExtensions {
}
}
- int FindIndex(StateEvent needle) {
+ int FindIndex(MatrixEvent needle) {
for (int i = 0; i < oldState.Count; i++) {
var old = oldState[i];
if (old.Type == needle.Type && old.StateKey == needle.StateKey)
@@ -38,7 +38,7 @@ public static class EnumerableExtensions {
}
}
- public static void MergeStateEventLists(this IList<StateEventResponse> oldState, IList<StateEventResponse> newState) {
+ public static void MergeStateEventLists(this IList<MatrixEventResponse> oldState, IList<MatrixEventResponse> newState) {
foreach (var e in newState) {
switch (FindIndex(e)) {
case -1:
@@ -50,7 +50,7 @@ public static class EnumerableExtensions {
}
}
- int FindIndex(StateEventResponse needle) {
+ int FindIndex(MatrixEventResponse needle) {
for (int i = 0; i < oldState.Count; i++) {
var old = oldState[i];
if (old.Type == needle.Type && old.StateKey == needle.StateKey)
@@ -61,7 +61,7 @@ public static class EnumerableExtensions {
}
}
- public static void MergeStateEventLists(this List<StateEventResponse> oldState, List<StateEventResponse> newState) {
+ public static void MergeStateEventLists(this List<MatrixEventResponse> oldState, List<MatrixEventResponse> newState) {
foreach (var e in newState) {
switch (FindIndex(e)) {
case -1:
@@ -73,7 +73,7 @@ public static class EnumerableExtensions {
}
}
- int FindIndex(StateEventResponse needle) {
+ int FindIndex(MatrixEventResponse needle) {
for (int i = 0; i < oldState.Count; i++) {
var old = oldState[i];
if (old.Type == needle.Type && old.StateKey == needle.StateKey)
diff --git a/LibMatrix/Extensions/JsonElementExtensions.cs b/LibMatrix/Extensions/JsonElementExtensions.cs
index dfec95b..9225f58 100644
--- a/LibMatrix/Extensions/JsonElementExtensions.cs
+++ b/LibMatrix/Extensions/JsonElementExtensions.cs
@@ -8,7 +8,7 @@ namespace LibMatrix.Extensions;
public static class JsonElementExtensions {
public static bool FindExtraJsonElementFields(this JsonElement obj, Type objectType, string objectPropertyName) {
if (objectPropertyName == "content" && objectType == typeof(JsonObject))
- objectType = typeof(StateEventResponse);
+ objectType = typeof(MatrixEventResponse);
// if (t == typeof(JsonNode))
// return false;
@@ -35,9 +35,9 @@ public static class JsonElementExtensions {
continue;
}
- if (field.Name == "content" && (objectType == typeof(StateEventResponse) || objectType == typeof(StateEvent))) {
+ if (field.Name == "content" && (objectType == typeof(MatrixEventResponse) || objectType == typeof(MatrixEvent))) {
unknownPropertyFound |= field.FindExtraJsonPropertyFieldsByValueKind(
- StateEvent.GetStateEventType(obj.GetProperty("type").GetString()!), // We expect type to always be present
+ MatrixEvent.GetEventType(obj.GetProperty("type").GetString()!), // We expect type to always be present
mappedProperty.PropertyType);
continue;
}
diff --git a/LibMatrix/Extensions/MatrixHttpClient.Single.cs b/LibMatrix/Extensions/MatrixHttpClient.Single.cs
index baa4a2c..cd82071 100644
--- a/LibMatrix/Extensions/MatrixHttpClient.Single.cs
+++ b/LibMatrix/Extensions/MatrixHttpClient.Single.cs
@@ -4,13 +4,14 @@ using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Net.Sockets;
using System.Reflection;
+using System.Security.Authentication;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
-using ArcaneLibs;
using ArcaneLibs.Extensions;
-using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Requests;
namespace LibMatrix.Extensions;
@@ -32,12 +33,13 @@ public class MatrixHttpClient {
};
}
catch (PlatformNotSupportedException e) {
- Console.WriteLine("Failed to create HttpClient with connection pooling, continuing without connection pool!");
- Console.WriteLine("Original exception (safe to ignore!):");
- Console.WriteLine(e);
+ Console.WriteLine("HTTP connection pooling is not supported on this platform, continuing without connection pooling!");
+ // Console.WriteLine("Original exception (safe to ignore!):");
+ // Console.WriteLine(e);
Client = new HttpClient {
- DefaultRequestVersion = new Version(3, 0)
+ DefaultRequestVersion = new Version(3, 0),
+ Timeout = TimeSpan.FromDays(1)
};
}
catch (Exception e) {
@@ -55,6 +57,20 @@ public class MatrixHttpClient {
public Dictionary<string, string> AdditionalQueryParameters { get; set; } = new();
public Uri? BaseAddress { get; set; }
+ public static bool DefaultRetryOnNetworkError { get; set; } = true;
+ public static bool DefaultRetryOnMatrixError { get; set; } = true;
+ public bool RetryOnNetworkError { get; set; } = DefaultRetryOnNetworkError;
+ public bool RetryOnMatrixError { get; set; } = DefaultRetryOnMatrixError;
+
+ public static int DefaultMinRetryIntervalMs { get; set; } = 1000;
+ public static int DefaultMaxRetryIntervalMs { get; set; } = 2000;
+ public static int DefaultMaxRetries { get; set; } = 20;
+
+ public int MinRetryIntervalMs { get; set; } = DefaultMinRetryIntervalMs;
+ public int MaxRetryIntervalMs { get; set; } = DefaultMaxRetryIntervalMs;
+ public int MaxRetries { get; set; } = DefaultMaxRetries;
+
+ private Dictionary<HttpRequestMessage, int> _retries = [];
// default headers, not bound to client
public HttpRequestHeaders DefaultRequestHeaders { get; set; } =
@@ -101,22 +117,22 @@ public class MatrixHttpClient {
responseMessage = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
}
catch (Exception e) {
- if (attempt >= 5) {
- Console.WriteLine(
- $"Failed to send request {request.Method} {BaseAddress}{request.RequestUri} ({Util.BytesToString(request.Content?.Headers.ContentLength ?? 0)}):\n{e}");
- throw;
- }
-
- if (e is TaskCanceledException or TimeoutException or HttpRequestException) {
- if (request.Method == HttpMethod.Get && !cancellationToken.IsCancellationRequested) {
- await Task.Delay(Random.Shared.Next(500, 2500), cancellationToken);
- request.ResetSendStatus();
- return await SendUnhandledAsync(request, cancellationToken, attempt + 1);
- }
- }
- else if (!e.ToString().StartsWith("TypeError: NetworkError"))
- Console.WriteLine(
- $"Failed to send request {request.Method} {BaseAddress}{request.RequestUri} ({Util.BytesToString(request.Content?.Headers.ContentLength ?? 0)}):\n{e}");
+ // if (attempt >= 5) {
+ // Console.WriteLine(
+ // $"Failed to send request {request.Method} {BaseAddress}{request.RequestUri} ({Util.BytesToString(request.Content?.Headers.ContentLength ?? 0)}):\n{e}");
+ // throw;
+ // }
+ //
+ // if (e is TaskCanceledException or TimeoutException or HttpRequestException) {
+ // if (request.Method == HttpMethod.Get && !cancellationToken.IsCancellationRequested) {
+ // await Task.Delay(Random.Shared.Next(100, 1000), cancellationToken);
+ // request.ResetSendStatus();
+ // return await SendUnhandledAsync(request, cancellationToken, attempt + 1);
+ // }
+ // }
+ // else if (!e.ToString().StartsWith("TypeError: NetworkError"))
+ // Console.WriteLine(
+ // $"Failed to send request {request.Method} {BaseAddress}{request.RequestUri} ({Util.BytesToString(request.Content?.Headers.ContentLength ?? 0)}):\n{e}");
throw;
}
@@ -144,15 +160,73 @@ public class MatrixHttpClient {
"X-Content-Security-Policy",
"Referrer-Policy",
"X-Robots-Tag",
- "Content-Security-Policy"
+ "Content-Security-Policy",
+ "Alt-Svc",
+ // evil
+ "CF-Cache-Status",
+ "CF-Ray",
+ "x-amz-request-id",
+ "x-do-app-origin",
+ "x-do-orig-status",
+ "x-rgw-object-type",
+ "Report-To"
]));
return responseMessage;
}
public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken = default) {
- var responseMessage = await SendUnhandledAsync(request, cancellationToken);
- if (responseMessage.IsSuccessStatusCode) return responseMessage;
+ _retries.TryAdd(request, MaxRetries);
+ HttpResponseMessage responseMessage;
+ try {
+ responseMessage = await SendUnhandledAsync(request, cancellationToken);
+ }
+ catch (HttpRequestException ex) {
+ if (RetryOnNetworkError) {
+ if (_retries[request]-- <= 0) throw;
+ // browser exceptions
+ if (ex.InnerException?.GetType().FullName == "System.Runtime.InteropServices.JavaScript.JSException")
+ Console.WriteLine($"Got JSException, likely a CORS error due to a reverse proxy misconfiguration and error, retrying ({_retries[request]} left)...");
+ // native exceptions
+ else if (ex.InnerException is SocketException sockEx)
+ if (sockEx.SocketErrorCode == SocketError.HostNotFound) {
+ throw new LibMatrixNetworkException(ex) {
+ Error = $"Host {request.RequestUri?.Host ?? "(null)"} not found",
+ ErrorCode = LibMatrixNetworkException.ErrorCodes.RLM_NET_UNKNOWN_HOST
+ };
+ }
+ else { } // empty
+ else if (ex.InnerException is AuthenticationException authEx)
+ if (authEx.Message.Contains("The remote certificate is invalid")) {
+ throw new LibMatrixNetworkException(ex) {
+ Error = ex.Message,
+ ErrorCode = LibMatrixNetworkException.ErrorCodes.RLM_NET_INVALID_REMOTE_CERTIFICATE
+ };
+ }
+ else { } // empty
+
+ else
+ Console.WriteLine(new {
+ ex.HttpRequestError,
+ ex.StatusCode,
+ ex.Data,
+ ex.Message,
+ InnerException = ex.InnerException?.ToString(),
+ InnerExceptionType = ex.InnerException?.GetType().FullName
+ }.ToJson());
+
+ await Task.Delay(Random.Shared.Next(MinRetryIntervalMs, MaxRetryIntervalMs), cancellationToken);
+ request.ResetSendStatus();
+ return await SendAsync(request, cancellationToken);
+ }
+
+ throw;
+ }
+
+ if (responseMessage.IsSuccessStatusCode) {
+ _retries.Remove(request);
+ return responseMessage;
+ }
//retry on gateway timeout
// if (responseMessage.StatusCode == HttpStatusCode.GatewayTimeout) {
@@ -169,31 +243,46 @@ public class MatrixHttpClient {
};
// if (!content.StartsWith('{')) throw new InvalidDataException("Encountered invalid data:\n" + content);
- if (!content.TrimStart().StartsWith('{')) {
- responseMessage.EnsureSuccessStatusCode();
- throw new InvalidDataException("Encountered invalid data:\n" + content);
- }
- //we have a matrix error
+ if (content.TrimStart().StartsWith('{')) {
+ //we have a matrix error, most likely
+ MatrixException? ex;
+ try {
+ ex = JsonSerializer.Deserialize<MatrixException>(content);
+ }
+ catch (JsonException e) {
+ throw new LibMatrixException() {
+ ErrorCode = "M_INVALID_JSON",
+ Error = e.Message + "\nBody:\n" + await responseMessage.Content.ReadAsStringAsync(cancellationToken)
+ };
+ }
- MatrixException? ex;
- try {
- ex = JsonSerializer.Deserialize<MatrixException>(content);
+ Debug.Assert(ex != null, nameof(ex) + " != null");
+ ex.RawContent = content;
+ // Console.WriteLine($"Failed to send request: {ex}");
+ if (ex.ErrorCode == MatrixException.ErrorCodes.M_LIMIT_EXCEEDED) {
+ // if (ex.RetryAfterMs is null) throw ex!;
+ //we have a ratelimit error
+ await Task.Delay(ex.RetryAfterMs ?? responseMessage.Headers.RetryAfter?.Delta?.Milliseconds ?? MinRetryIntervalMs, cancellationToken);
+ request.ResetSendStatus();
+ return await SendAsync(request, cancellationToken);
+ }
+
+ throw ex;
}
- catch (JsonException e) {
- throw new LibMatrixException() {
- ErrorCode = "M_INVALID_JSON",
- Error = e.Message + "\nBody:\n" + await responseMessage.Content.ReadAsStringAsync(cancellationToken)
- };
+
+ if (responseMessage.StatusCode == HttpStatusCode.BadGateway) {
+ // spread out retries
+ if (RetryOnNetworkError) {
+ if (_retries[request]-- <= 0) throw new InvalidDataException("Encountered invalid data:\n" + content);
+ Console.WriteLine($"Got 502 Bad Gateway, retrying ({_retries[request]} left)...");
+ await Task.Delay(Random.Shared.Next(MinRetryIntervalMs, MaxRetryIntervalMs), cancellationToken);
+ request.ResetSendStatus();
+ return await SendAsync(request, cancellationToken);
+ }
}
- Debug.Assert(ex != null, nameof(ex) + " != null");
- ex.RawContent = content;
- // Console.WriteLine($"Failed to send request: {ex}");
- if (ex.RetryAfterMs is null) throw ex!;
- //we have a ratelimit error
- await Task.Delay(ex.RetryAfterMs.Value, cancellationToken);
- request.ResetSendStatus();
- return await SendAsync(request, cancellationToken);
+ responseMessage.EnsureSuccessStatusCode();
+ throw new InvalidDataException("Encountered invalid data:\n" + content);
}
// GetAsync
@@ -241,22 +330,16 @@ public class MatrixHttpClient {
options = GetJsonSerializerOptions(options);
var request = new HttpRequestMessage(HttpMethod.Put, requestUri);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
- request.Content = new StringContent(JsonSerializer.Serialize(value, value.GetType(), options),
- Encoding.UTF8, "application/json");
+ request.Content = JsonContent.Create(value, value.GetType(), new MediaTypeHeaderValue("application/json", "utf-8"), options);
return await SendAsync(request, cancellationToken);
}
public async Task<HttpResponseMessage> PostAsJsonAsync<T>([StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, T value, JsonSerializerOptions? options = null,
CancellationToken cancellationToken = default) where T : notnull {
- options ??= new JsonSerializerOptions();
- options.Converters.Add(new JsonFloatStringConverter());
- options.Converters.Add(new JsonDoubleStringConverter());
- options.Converters.Add(new JsonDecimalStringConverter());
- options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
+ options = GetJsonSerializerOptions(options);
var request = new HttpRequestMessage(HttpMethod.Post, requestUri);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
- request.Content = new StringContent(JsonSerializer.Serialize(value, value.GetType(), options),
- Encoding.UTF8, "application/json");
+ request.Content = JsonContent.Create(value, value.GetType(), new MediaTypeHeaderValue("application/json", "utf-8"), options);
return await SendAsync(request, cancellationToken);
}
@@ -286,9 +369,9 @@ public class MatrixHttpClient {
return await SendAsync(request, cancellationToken);
}
- public async Task DeleteAsync(string url) {
+ public async Task<HttpResponseMessage> DeleteAsync(string url) {
var request = new HttpRequestMessage(HttpMethod.Delete, url);
- await SendAsync(request);
+ return await SendAsync(request);
}
public async Task<HttpResponseMessage> DeleteAsJsonAsync<T>(string url, T payload) {
@@ -297,5 +380,66 @@ public class MatrixHttpClient {
};
return await SendAsync(request);
}
+
+ public async Task<HttpResponseMessage> PostAsyncEnumerableAsJsonAsync<T>(string url, IAsyncEnumerable<T> payload, JsonSerializerOptions? options = null,
+ CancellationToken cancellationToken = default) {
+ options = GetJsonSerializerOptions(options);
+ var request = new HttpRequestMessage(HttpMethod.Post, url);
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+ request.Content = new AsyncEnumerableJsonContent<T>(payload, options);
+ return await SendAsync(request, cancellationToken);
+ }
+
+ public async Task<HttpResponseMessage> PutAsyncEnumerableAsJsonAsync<T>(string url, IAsyncEnumerable<T> payload, JsonSerializerOptions? options = null,
+ CancellationToken cancellationToken = default) {
+ options = GetJsonSerializerOptions(options);
+ var request = new HttpRequestMessage(HttpMethod.Put, url);
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+ request.Content = new AsyncEnumerableJsonContent<T>(payload, options);
+ return await SendAsync(request, cancellationToken);
+ }
+}
+
+public class AsyncEnumerableJsonContent<T>(IAsyncEnumerable<T> payload, JsonSerializerOptions? options) : HttpContent {
+ protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context) {
+ await using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions {
+ Indented = options?.WriteIndented ?? true,
+ Encoder = options?.Encoder,
+ IndentCharacter = options?.IndentCharacter ?? ' ',
+ IndentSize = options?.IndentSize ?? 4,
+ MaxDepth = options?.MaxDepth ?? 0,
+ NewLine = options?.NewLine ?? Environment.NewLine
+ });
+
+ writer.WriteStartArray();
+ await writer.FlushAsync();
+ await stream.FlushAsync();
+
+ await foreach (var item in payload) {
+ if (item is null) {
+ writer.WriteNullValue();
+ }
+ else {
+ using var memoryStream = new MemoryStream();
+ await JsonSerializer.SerializeAsync(memoryStream, item, item.GetType(), options);
+
+ memoryStream.Position = 0;
+ var jsonBytes = memoryStream.ToArray();
+ writer.WriteRawValue(jsonBytes, skipInputValidation: true);
+ }
+
+ await writer.FlushAsync();
+ await stream.FlushAsync();
+ }
+
+ writer.WriteEndArray();
+ await writer.FlushAsync();
+ await stream.FlushAsync();
+ }
+
+ protected override bool TryComputeLength(out long length) {
+ length = 0;
+ return false;
+ }
}
#endif
\ No newline at end of file
diff --git a/LibMatrix/Helpers/MessageBuilder.cs b/LibMatrix/Helpers/MessageBuilder.cs
index 5e2b1b7..f753bf7 100644
--- a/LibMatrix/Helpers/MessageBuilder.cs
+++ b/LibMatrix/Helpers/MessageBuilder.cs
@@ -37,6 +37,10 @@ public class MessageBuilder(string msgType = "m.text", string format = "org.matr
return this;
}
+ public static string GetColoredBody(string color, string body) {
+ return $"<font color=\"{color}\">{body}</font>";
+ }
+
public MessageBuilder WithColoredBody(string color, string body) {
Content.Body += body;
Content.FormattedBody += $"<font color=\"{color}\">{body}</font>";
@@ -91,9 +95,33 @@ public class MessageBuilder(string msgType = "m.text", string format = "org.matr
return this;
}
- public MessageBuilder WithMention(string id, string? displayName = null) {
- Content.Body += $"@{displayName ?? id}";
+ public MessageBuilder WithMention(string id, string? displayName = null, string[]? vias = null, bool useIdInPlainText = false, bool useLinkInPlainText = false) {
+ if (!useLinkInPlainText) Content.Body += $"@{(useIdInPlainText ? id : displayName ?? id)}";
+ else {
+ Content.Body += $"https://matrix.to/#/{id}";
+ if (vias is { Length: > 0 }) Content.Body += $"?via={string.Join("&via=", vias)}";
+ }
+
Content.FormattedBody += $"<a href=\"https://matrix.to/#/{id}\">{displayName ?? id}</a>";
+ if (id == "@room") {
+ Content.Mentions ??= new();
+ Content.Mentions.Room = true;
+ }
+ else if (id.StartsWith('@')) {
+ Content.Mentions ??= new();
+ Content.Mentions.Users ??= new();
+ Content.Mentions.Users.Add(id);
+ }
+
+ return this;
+ }
+
+ public MessageBuilder WithRoomMention() {
+ // Legacy push rules support
+ Content.Body += "@room";
+ Content.FormattedBody += "@room";
+ Content.Mentions ??= new();
+ Content.Mentions.Room = true;
return this;
}
diff --git a/LibMatrix/Helpers/MessageFormatter.cs b/LibMatrix/Helpers/MessageFormatter.cs
index 1b9b4f3..780ac0e 100644
--- a/LibMatrix/Helpers/MessageFormatter.cs
+++ b/LibMatrix/Helpers/MessageFormatter.cs
@@ -30,8 +30,11 @@ public static class MessageFormatter {
public static string HtmlFormatMention(string id, string? displayName = null) => $"<a href=\"https://matrix.to/#/{id}\">{displayName ?? id}</a>";
- public static string HtmlFormatMessageLink(string roomId, string eventId, string[]? servers = null, string? displayName = null) {
- if (servers is not { Length: > 0 }) servers = new[] { roomId.Split(':', 2)[1] };
+ public static string HtmlFormatMessageLink(string roomId, string eventId, string[] servers, string? displayName = null) {
+ if (servers is not { Length: > 0 })
+ servers = roomId.Contains(':')
+ ? [roomId.Split(':', 2)[1]]
+ : throw new ArgumentException("Message links must contain a list of via's for v12+ rooms!", nameof(servers));
return $"<a href=\"https://matrix.to/#/{roomId}/{eventId}?via={string.Join("&via=", servers)}\">{displayName ?? eventId}</a>";
}
diff --git a/LibMatrix/Helpers/RoomBuilder.cs b/LibMatrix/Helpers/RoomBuilder.cs
new file mode 100644
index 0000000..a292f33
--- /dev/null
+++ b/LibMatrix/Helpers/RoomBuilder.cs
@@ -0,0 +1,279 @@
+using System.Diagnostics;
+using System.Runtime.Intrinsics.X86;
+using System.Text.RegularExpressions;
+using ArcaneLibs.Extensions;
+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; } = "12";
+ public RoomNameEventContent Name { get; set; } = new();
+ public RoomTopicEventContent Topic { get; set; } = new();
+ public RoomAvatarEventContent Avatar { get; set; } = new();
+ public RoomCanonicalAliasEventContent CanonicalAlias { get; set; } = new();
+ public string AliasLocalPart { get; set; } = string.Empty;
+ public bool IsFederatable { get; set; } = true;
+ public long OwnPowerLevel { get; set; } = MatrixConstants.MaxSafeJsonInteger;
+
+ public RoomJoinRulesEventContent JoinRules { get; set; } = new() {
+ JoinRule = RoomJoinRulesEventContent.JoinRules.Public
+ };
+
+ public RoomHistoryVisibilityEventContent HistoryVisibility { get; set; } = new() {
+ HistoryVisibility = RoomHistoryVisibilityEventContent.HistoryVisibilityTypes.Shared
+ };
+
+ public RoomGuestAccessEventContent GuestAccess { get; set; } = new() {
+ GuestAccess = "forbidden"
+ };
+
+ public RoomServerAclEventContent ServerAcls { get; set; } = new() {
+ AllowIpLiterals = false
+ };
+
+ public RoomEncryptionEventContent Encryption { get; set; } = new();
+
+ /// <summary>
+ /// State events to be sent *before* room access is configured. Keep this small!
+ /// </summary>
+ public List<MatrixEvent> ImportantState { get; set; } = [];
+
+ /// <summary>
+ /// State events to be sent *after* room access is configured, but before invites are sent.
+ /// </summary>
+ public List<MatrixEvent> InitialState { get; set; } = [];
+
+ /// <summary>
+ /// Users to invite, with optional reason
+ /// </summary>
+ public Dictionary<string, string?> Invites { get; set; } = [];
+
+ /// <summary>
+ /// Users to ban, with optional reason
+ /// </summary>
+ public Dictionary<string, string?> Bans { get; set; } = [];
+
+ public RoomPowerLevelEventContent PowerLevels { get; set; } = new() {
+ EventsDefault = 0,
+ UsersDefault = 0,
+ Kick = 50,
+ Invite = 50,
+ Ban = 50,
+ Redact = 50,
+ StateDefault = 50,
+ NotificationsPl = new() {
+ Room = 50
+ },
+ Users = [],
+ Events = new Dictionary<string, long> {
+ { RoomAvatarEventContent.EventId, 50 },
+ { RoomCanonicalAliasEventContent.EventId, 50 },
+ { RoomEncryptionEventContent.EventId, 100 },
+ { RoomHistoryVisibilityEventContent.EventId, 100 },
+ { RoomGuestAccessEventContent.EventId, 100 },
+ { RoomNameEventContent.EventId, 50 },
+ { RoomPowerLevelEventContent.EventId, 100 },
+ { RoomServerAclEventContent.EventId, 100 },
+ { RoomTombstoneEventContent.EventId, 150 },
+ { RoomPolicyServerEventContent.EventId, 100 },
+ { RoomPinnedEventContent.EventId, 50 },
+ // recommended extensions
+ { "im.vector.modular.widgets", 50 },
+ // { "m.reaction", 0 }, // we probably don't want these to end up as room state
+ // - prevent calls
+ { "io.element.voice_broadcast_info", 50 },
+ { "org.matrix.msc3401.call", 50 },
+ { "org.matrix.msc3401.call.member", 50 },
+ }
+ };
+
+ public Dictionary<string, object> AdditionalCreationContent { get; set; } = new();
+ public List<string> AdditionalCreators { get; set; } = new();
+
+ public virtual async Task<GenericRoom> Create(AuthenticatedHomeserverGeneric homeserver) {
+ var crq = new CreateRoomRequest {
+ PowerLevelContentOverride = new() {
+ EventsDefault = 1000000,
+ UsersDefault = 1000000,
+ Kick = 1000000,
+ Invite = 1000000,
+ Ban = 1000000,
+ Redact = 1000000,
+ StateDefault = 1000000,
+ NotificationsPl = new() {
+ Room = 1000000
+ },
+ Users = new() {
+ { homeserver.WhoAmI.UserId, MatrixConstants.MaxSafeJsonInteger }
+ },
+ Events = new Dictionary<string, long> {
+ { RoomAvatarEventContent.EventId, 1000000 },
+ { RoomCanonicalAliasEventContent.EventId, 1000000 },
+ { RoomEncryptionEventContent.EventId, 1000000 },
+ { RoomHistoryVisibilityEventContent.EventId, 1000000 },
+ { RoomGuestAccessEventContent.EventId, 1000000 },
+ { RoomNameEventContent.EventId, 1000000 },
+ { RoomPowerLevelEventContent.EventId, 1000000 },
+ { RoomServerAclEventContent.EventId, 1000000 },
+ { RoomTombstoneEventContent.EventId, 1000000 },
+ { RoomPolicyServerEventContent.EventId, 1000000 }
+ },
+ },
+ Visibility = "private",
+ RoomVersion = Version
+ };
+
+ if (!string.IsNullOrWhiteSpace(Type))
+ crq.CreationContent.Add("type", Type);
+
+ if (!IsFederatable)
+ crq.CreationContent.Add("m.federate", false);
+
+ AdditionalCreators.RemoveAll(string.IsNullOrWhiteSpace);
+ if (V12PlusRoomVersions.Contains(Version)) {
+ crq.PowerLevelContentOverride.Users.Remove(homeserver.WhoAmI.UserId);
+ PowerLevels.Users?.Remove(homeserver.WhoAmI.UserId);
+ if (AdditionalCreators is { Count: > 0 }) {
+ crq.CreationContent.Add("additional_creators", AdditionalCreators);
+ foreach (var user in AdditionalCreators)
+ PowerLevels.Users?.Remove(user);
+ }
+ }
+
+ foreach (var kvp in AdditionalCreationContent) {
+ crq.CreationContent.Add(kvp.Key, kvp.Value);
+ }
+
+ var room = await homeserver.CreateRoom(crq);
+
+ Console.WriteLine("Press any key to continue...");
+ Console.ReadKey(true);
+ await SetBasicRoomInfoAsync(room);
+ await SetStatesAsync(room, ImportantState);
+ await SetAccessAsync(room);
+ await SetStatesAsync(room, InitialState);
+ await SendInvites(room);
+
+ return room;
+ }
+
+ 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);
+ }
+ catch (MatrixException e) {
+ Console.Error.WriteLine("Failed to invite {0} to {1}: {2}", kvp.Key, room.RoomId, e.Message);
+ }
+ });
+
+ await Task.WhenAll(inviteTasks);
+ }
+
+ private async Task SetStatesAsync(GenericRoom room, List<MatrixEvent> state) {
+ if (state.Count == 0) return;
+ await room.BulkSendEventsAsync(state);
+ // We chunk this up to try to avoid hitting reverse proxy timeouts
+ // foreach (var group in state.Chunk(chunkSize)) {
+ // var sw = Stopwatch.StartNew();
+ // await room.BulkSendEventsAsync(group);
+ // if (sw.ElapsedMilliseconds > 5000) {
+ // chunkSize = Math.Max(chunkSize / 2, 1);
+ // Console.WriteLine($"Warning: Sending {group.Length} state events took {sw.ElapsedMilliseconds}ms, which is quite long. Reducing chunk size to {chunkSize}.");
+ // }
+ // }
+ // int chunkSize = 50;
+ // for (int i = 0; i < state.Count; i += chunkSize) {
+ // var chunk = state.Skip(i).Take(chunkSize).ToList();
+ // if (chunk.Count == 0) continue;
+ //
+ // var sw = Stopwatch.StartNew();
+ // await room.BulkSendEventsAsync(chunk, forceSyncInterval: chunk.Count + 1);
+ // Console.WriteLine($"Sent {chunk.Count} state events in {sw.ElapsedMilliseconds}ms. {state.Count - (i + chunk.Count)} remaining.");
+ // // if (sw.ElapsedMilliseconds > 45000) {
+ // // chunkSize = Math.Max(chunkSize / 3, 1);
+ // // Console.WriteLine($"Warning: Sending {chunk.Count} state events took {sw.ElapsedMilliseconds}ms, which is dangerously long. Reducing chunk size to {chunkSize}.");
+ // // }
+ // // else if (sw.ElapsedMilliseconds > 30000) {
+ // // chunkSize = Math.Max(chunkSize / 2, 1);
+ // // Console.WriteLine($"Warning: Sending {chunk.Count} state events took {sw.ElapsedMilliseconds}ms, which is quite long. Reducing chunk size to {chunkSize}.");
+ // // }
+ // // else if (sw.ElapsedMilliseconds < 10000) {
+ // // chunkSize = Math.Min((int)(chunkSize * 1.2), 1000);
+ // // Console.WriteLine($"Info: Sending {chunk.Count} state events took {sw.ElapsedMilliseconds}ms, increasing chunk size to {chunkSize}.");
+ // // }
+ // }
+ }
+
+ private async Task SetBasicRoomInfoAsync(GenericRoom room) {
+ if (!string.IsNullOrWhiteSpace(Name.Name))
+ await room.SendStateEventAsync(RoomNameEventContent.EventId, Name);
+
+ if (!string.IsNullOrWhiteSpace(Topic.Topic))
+ await room.SendStateEventAsync(RoomTopicEventContent.EventId, Topic);
+
+ if (!string.IsNullOrWhiteSpace(Avatar.Url))
+ await room.SendStateEventAsync(RoomAvatarEventContent.EventId, Avatar);
+
+ if (!string.IsNullOrWhiteSpace(AliasLocalPart))
+ CanonicalAlias.Alias = $"#{AliasLocalPart}:{room.Homeserver.ServerName}";
+
+ if (!string.IsNullOrWhiteSpace(CanonicalAlias.Alias)) {
+ await room.Homeserver.SetRoomAliasAsync(CanonicalAlias.Alias!, room.RoomId);
+ await room.SendStateEventAsync(RoomCanonicalAliasEventContent.EventId, CanonicalAlias);
+ }
+
+ if (!string.IsNullOrWhiteSpace(Encryption.Algorithm))
+ await room.SendStateEventAsync(RoomEncryptionEventContent.EventId, Encryption);
+ }
+
+ private async Task SetAccessAsync(GenericRoom room) {
+ if (!V12PlusRoomVersions.Contains(Version))
+ PowerLevels.Users![room.Homeserver.WhoAmI.UserId] = OwnPowerLevel;
+ else {
+ PowerLevels.Users!.Remove(room.Homeserver.WhoAmI.UserId);
+ foreach (var additionalCreator in AdditionalCreators) {
+ PowerLevels.Users!.Remove(additionalCreator);
+ }
+ }
+
+ await room.SendStateEventAsync(RoomPowerLevelEventContent.EventId, PowerLevels);
+
+ if (!string.IsNullOrWhiteSpace(HistoryVisibility.HistoryVisibility))
+ await room.SendStateEventAsync(RoomHistoryVisibilityEventContent.EventId, HistoryVisibility);
+
+ if (!string.IsNullOrWhiteSpace(JoinRules.JoinRuleValue))
+ await room.SendStateEventAsync(RoomJoinRulesEventContent.EventId, JoinRules);
+ }
+}
+
+public class MatrixConstants {
+ public const long MaxSafeJsonInteger = 9007199254740991L; // 2^53 - 1
+}
\ No newline at end of file
diff --git a/LibMatrix/Helpers/RoomUpgradeBuilder.cs b/LibMatrix/Helpers/RoomUpgradeBuilder.cs
new file mode 100644
index 0000000..ced0ef3
--- /dev/null
+++ b/LibMatrix/Helpers/RoomUpgradeBuilder.cs
@@ -0,0 +1,232 @@
+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;
+
+public class RoomUpgradeBuilder : RoomBuilder {
+ public RoomUpgradeOptions UpgradeOptions { get; set; } = new();
+ public string OldRoomId { get; set; } = string.Empty;
+ public bool CanUpgrade { get; private set; }
+ public Dictionary<string, object> AdditionalTombstoneContent { get; set; } = new();
+
+ private List<Type> basePolicyTypes = [];
+
+ public async Task ImportAsync(GenericRoom OldRoom) {
+ var sw = Stopwatch.StartNew();
+ var total = 0;
+
+ basePolicyTypes = ClassCollector<PolicyRuleEventContent>.ResolveFromAllAccessibleAssemblies().ToList();
+ Console.WriteLine($"Found {basePolicyTypes.Count} policy types in {sw.ElapsedMilliseconds}ms");
+ CanUpgrade = (
+ (await OldRoom.GetPowerLevelsAsync())?.UserHasStatePermission(OldRoom.Homeserver.UserId, RoomTombstoneEventContent.EventId)
+ ?? (await OldRoom.GetRoomCreatorsAsync()).Contains(OldRoom.Homeserver.UserId)
+ )
+ || (OldRoom.IsV12PlusRoomId && (await OldRoom.GetRoomCreatorsAsync()).Contains(OldRoom.Homeserver.UserId));
+
+ await foreach (var srcEvt in OldRoom.GetFullStateAsync()) {
+ total++;
+ if (srcEvt is null) continue;
+ var evt = srcEvt;
+
+ if (UpgradeOptions.UpgradeUnstableValues) {
+ evt = UpgradeUnstableValues(evt);
+ }
+
+ if (evt.StateKey == "") {
+ if (evt.Type == RoomCreateEventContent.EventId)
+ foreach (var (key, value) in evt.RawContent) {
+ if (key is "room_version" or "creator") continue;
+ if (key == "type")
+ Type = value!.GetValue<string>();
+ else AdditionalCreationContent[key] = value;
+ }
+ else if (evt.Type == RoomNameEventContent.EventId)
+ Name = evt.ContentAs<RoomNameEventContent>()!;
+ else if (evt.Type == RoomTopicEventContent.EventId)
+ Topic = evt.ContentAs<RoomTopicEventContent>()!;
+ else if (evt.Type == RoomAvatarEventContent.EventId)
+ Avatar = evt.ContentAs<RoomAvatarEventContent>()!;
+ else if (evt.Type == RoomCanonicalAliasEventContent.EventId) {
+ CanonicalAlias = evt.ContentAs<RoomCanonicalAliasEventContent>()!;
+ AliasLocalPart = CanonicalAlias.Alias?.Split(':', 2).FirstOrDefault()?[1..] ?? string.Empty;
+ }
+ else if (evt.Type == RoomJoinRulesEventContent.EventId)
+ JoinRules = evt.ContentAs<RoomJoinRulesEventContent>()!;
+ else if (evt.Type == RoomHistoryVisibilityEventContent.EventId)
+ HistoryVisibility = evt.ContentAs<RoomHistoryVisibilityEventContent>()!;
+ else if (evt.Type == RoomGuestAccessEventContent.EventId)
+ GuestAccess = evt.ContentAs<RoomGuestAccessEventContent>()!;
+ else if (evt.Type == RoomServerAclEventContent.EventId)
+ ServerAcls = evt.ContentAs<RoomServerAclEventContent>()!;
+ else if (evt.Type == RoomPowerLevelEventContent.EventId) {
+ PowerLevels = evt.ContentAs<RoomPowerLevelEventContent>()!;
+ if (UpgradeOptions.InvitePowerlevelUsers && PowerLevels.Users != null)
+ foreach (var (userId, level) in PowerLevels.Users)
+ if (level > PowerLevels.UsersDefault)
+ Invites.Add(userId, "Room upgrade (had a power level)");
+ }
+ else if (evt.Type == RoomEncryptionEventContent.EventId)
+ Encryption = evt.ContentAs<RoomEncryptionEventContent>();
+ else if (evt.Type == RoomPinnedEventContent.EventId) ; // Discard as you can't cross reference pinned events
+ else
+ InitialState.Add(new() {
+ Type = evt.Type,
+ StateKey = evt.StateKey,
+ RawContent = evt.RawContent
+ });
+ }
+ else if (evt.Type == RoomMemberEventContent.EventId) {
+ 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);
+ }
+ else if (!UpgradeOptions.MigrateEmptyStateEvents && evt.RawContent.Count == 0) { } // skip empty state events
+ else if (basePolicyTypes.Contains(evt.MappedType)) ImportPolicyEventAsync(evt);
+ else
+ InitialState.Add(new() {
+ Type = evt.Type,
+ StateKey = evt.StateKey,
+ RawContent = evt.RawContent
+ });
+ }
+
+ Console.WriteLine($"Imported {total} state events from old room {OldRoom.RoomId} in {sw.ElapsedMilliseconds}ms");
+ }
+
+ private MatrixEventResponse UpgradeUnstableValues(MatrixEventResponse evt) {
+ if (evt.IsLegacyType) {
+ var oldType = evt.Type;
+ evt.Type = evt.MappedType.GetCustomAttributes<MatrixEventAttribute>().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<string>() == "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;
+ }
+
+ private void ImportPolicyEventAsync(MatrixEventResponse evt) {
+ var msc4321Options = UpgradeOptions.Msc4321PolicyListUpgradeOptions;
+ if (msc4321Options is { Enable: true, UpgradeType: Msc4321PolicyListUpgradeOptions.Msc4321PolicyListUpgradeType.Transition })
+ return; // this upgrade type doesnt copy policies
+ if (msc4321Options.Enable) {
+ evt.RawContent["org.matrix.msc4321.original_sender"] = evt.Sender;
+ evt.RawContent["org.matrix.msc4321.original_timestamp"] = evt.OriginServerTs;
+ evt.RawContent["org.matrix.msc4321.original_event_id"] = evt.EventId;
+ }
+
+ InitialState.Add(new() {
+ Type = evt.Type,
+ StateKey = evt.StateKey,
+ RawContent = evt.RawContent
+ });
+ }
+
+ public override async Task<GenericRoom> Create(AuthenticatedHomeserverGeneric homeserver) {
+ var oldRoom = homeserver.GetRoom(OldRoomId);
+ // set the previous room relation
+ AdditionalCreationContent["predecessor"] = new {
+ room_id = OldRoomId,
+ // event_id = (await oldRoom.GetMessagesAsync(limit: 1)).Chunk.Last().EventId
+ };
+
+ if (UpgradeOptions.NoopUpgrade) {
+ AliasLocalPart = null;
+ CanonicalAlias = new();
+ return await base.Create(homeserver);
+ }
+
+ // prepare old room first...
+ if (!string.IsNullOrWhiteSpace(AliasLocalPart)) {
+ var aliasResult = await homeserver.ResolveRoomAliasAsync($"#{AliasLocalPart}:{homeserver.ServerName}");
+ if (aliasResult?.RoomId == OldRoomId)
+ await homeserver.DeleteRoomAliasAsync($"#{AliasLocalPart}:{homeserver.ServerName}");
+ else
+ throw new LibMatrixException() {
+ ErrorCode = LibMatrixException.ErrorCodes.M_UNSUPPORTED,
+ Error = $"Cannot upgrade room {OldRoomId} as it has an alias that is not the same as the one tracked by the server! Server says: {aliasResult.RoomId}"
+ };
+ }
+
+ var room = await base.Create(homeserver);
+ if (CanUpgrade || UpgradeOptions.ForceUpgrade) {
+ if (UpgradeOptions.RoomUpgradeNotice != null) {
+ var noticeContent = await UpgradeOptions.RoomUpgradeNotice(room);
+ await oldRoom.SendMessageEventAsync(noticeContent);
+ }
+
+ var tombstoneContent = new RoomTombstoneEventContent {
+ Body = "This room has been upgraded to a new version.",
+ ReplacementRoom = room.RoomId
+ };
+
+ tombstoneContent.AdditionalData ??= [];
+ foreach (var (key, value) in AdditionalTombstoneContent)
+ tombstoneContent.AdditionalData[key] = value;
+
+ await oldRoom.SendStateEventAsync(RoomTombstoneEventContent.EventId, tombstoneContent);
+ }
+
+ return room;
+ }
+
+ 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; }
+ public bool UpgradeUnstableValues { get; set; }
+ public bool ForceUpgrade { get; set; }
+ public bool NoopUpgrade { get; set; }
+ public Msc4321PolicyListUpgradeOptions Msc4321PolicyListUpgradeOptions { get; set; } = new();
+
+ [JsonIgnore]
+ public Func<GenericRoom, Task<RoomMessageEventContent>>? RoomUpgradeNotice { get; set; } = async newRoom => new MessageBuilder()
+ .WithRoomMention()
+ .WithNewline()
+ .WithBody("This room has been upgraded to a new version. This version of the room will be kept as an archive.")
+ .WithNewline()
+ .WithBody("You can join the new room by clicking the link below:")
+ .WithNewline()
+ .WithMention(newRoom.RoomId, await newRoom.GetNameOrFallbackAsync(), vias: (await newRoom.GetHomeserversInRoom()).ToArray(), useLinkInPlainText: true)
+ .Build();
+ }
+
+ public class Msc4321PolicyListUpgradeOptions {
+ public bool Enable { get; set; } = true;
+ public Msc4321PolicyListUpgradeType UpgradeType { get; set; } = Msc4321PolicyListUpgradeType.Move;
+
+ [JsonConverter(typeof(JsonStringEnumConverter))]
+ public enum Msc4321PolicyListUpgradeType {
+ /// <summary>
+ /// Copy policies, unwatch old list
+ /// </summary>
+ Move,
+
+ /// <summary>
+ /// Don't copy policies, watch both lists
+ /// </summary>
+ Transition
+ }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Helpers/SyncHelper.cs b/LibMatrix/Helpers/SyncHelper.cs
index 6f2cacc..ebe653c 100644
--- a/LibMatrix/Helpers/SyncHelper.cs
+++ b/LibMatrix/Helpers/SyncHelper.cs
@@ -15,7 +15,7 @@ using Microsoft.Extensions.Logging;
namespace LibMatrix.Helpers;
public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logger = null, IStorageProvider? storageProvider = null) {
- private readonly Func<SyncResponse?, Task<SyncResponse?>> _msc4222EmulationSyncProcessor = new Msc4222EmulationSyncProcessor(homeserver).EmulateMsc4222;
+ private readonly Func<SyncResponse?, Task<SyncResponse?>> _msc4222EmulationSyncProcessor = new Msc4222EmulationSyncProcessor(homeserver, logger).EmulateMsc4222;
private SyncFilter? _filter;
private string? _namedFilterName;
@@ -25,7 +25,7 @@ public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logg
public string? Since { get; set; }
public int Timeout { get; set; } = 30000;
public string? SetPresence { get; set; }
-
+
/// <summary>
/// Disabling this uses a technically slower code path, useful for checking whether delay comes from waiting for server or deserialising responses
/// </summary>
@@ -37,11 +37,11 @@ public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logg
field = value;
if (value) {
AsyncSyncPreprocessors.Add(_msc4222EmulationSyncProcessor);
- Console.WriteLine($"Added MSC4222 emulation sync processor");
+ logger?.LogInformation($"Added MSC4222 emulation sync processor");
}
else {
AsyncSyncPreprocessors.Remove(_msc4222EmulationSyncProcessor);
- Console.WriteLine($"Removed MSC4222 emulation sync processor");
+ logger?.LogInformation($"Removed MSC4222 emulation sync processor");
}
}
} = false;
@@ -121,7 +121,7 @@ public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logg
}
if (storageProvider is null) {
- var res = await SyncAsyncInternal(cancellationToken, noDelay);
+ var res = await SyncAsyncInternal(cancellationToken, noDelay);
if (res is null) return null;
if (UseMsc4222StateAfter) res.Msc4222Method = SyncResponse.Msc4222SyncType.Server;
@@ -186,13 +186,12 @@ public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logg
else {
var httpResp = await homeserver.ClientHttpClient.GetAsync(url, cancellationToken ?? CancellationToken.None);
if (httpResp is null) throw new NullReferenceException("Failed to send HTTP request");
- logger?.LogInformation("Got sync response: {} bytes, {} elapsed", httpResp.GetContentLength(), sw.Elapsed);
+ var receivedTime = sw.Elapsed;
var deserializeSw = Stopwatch.StartNew();
- // var jsonResp = await httpResp.Content.ReadFromJsonAsync<JsonObject>(cancellationToken: cancellationToken ?? CancellationToken.None);
- // var resp = jsonResp.Deserialize<SyncResponse>();
resp = await httpResp.Content.ReadFromJsonAsync(cancellationToken: cancellationToken ?? CancellationToken.None,
jsonTypeInfo: SyncResponseSerializerContext.Default.SyncResponse);
- logger?.LogInformation("Deserialized sync response: {} bytes, {} elapsed, {} total", httpResp.GetContentLength(), deserializeSw.Elapsed, sw.Elapsed);
+ logger?.LogInformation("Deserialized sync response: {} bytes, {} response time, {} deserialize time, {} total", httpResp.GetContentLength(), receivedTime,
+ deserializeSw.Elapsed, sw.Elapsed);
}
var timeToWait = MinimumDelay.Subtract(sw.Elapsed);
@@ -299,9 +298,9 @@ public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logg
if (syncResponse.Rooms is { Join.Count: > 0 })
foreach (var updatedRoom in syncResponse.Rooms.Join) {
if (updatedRoom.Value.Timeline is null) continue;
- foreach (var stateEventResponse in updatedRoom.Value.Timeline.Events ?? []) {
- stateEventResponse.RoomId = updatedRoom.Key;
- var tasks = TimelineEventHandlers.Select(x => x(stateEventResponse)).ToList();
+ foreach (var MatrixEventResponse in updatedRoom.Value.Timeline.Events ?? []) {
+ MatrixEventResponse.RoomId = updatedRoom.Key;
+ var tasks = TimelineEventHandlers.Select(x => x(MatrixEventResponse)).ToList();
await Task.WhenAll(tasks);
}
}
@@ -320,12 +319,12 @@ public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logg
/// <summary>
/// Event fired when a timeline event is received
/// </summary>
- public List<Func<StateEventResponse, Task>> TimelineEventHandlers { get; } = new();
+ public List<Func<MatrixEventResponse, Task>> TimelineEventHandlers { get; } = new();
/// <summary>
/// Event fired when an account data event is received
/// </summary>
- public List<Func<StateEventResponse, Task>> AccountDataReceivedHandlers { get; } = new();
+ public List<Func<MatrixEventResponse, Task>> AccountDataReceivedHandlers { get; } = new();
/// <summary>
/// Event fired when an exception is thrown
diff --git a/LibMatrix/Helpers/SyncProcessors/Msc4222EmulationSyncProcessor.cs b/LibMatrix/Helpers/SyncProcessors/Msc4222EmulationSyncProcessor.cs
index 6cb42ca..c887f6e 100644
--- a/LibMatrix/Helpers/SyncProcessors/Msc4222EmulationSyncProcessor.cs
+++ b/LibMatrix/Helpers/SyncProcessors/Msc4222EmulationSyncProcessor.cs
@@ -1,16 +1,18 @@
using System.Diagnostics;
+using System.Timers;
using ArcaneLibs.Extensions;
using LibMatrix.Homeservers;
using LibMatrix.Responses;
+using Microsoft.Extensions.Logging;
namespace LibMatrix.Helpers.SyncProcessors;
-public class Msc4222EmulationSyncProcessor(AuthenticatedHomeserverGeneric homeserver) {
- private static bool StateEventsMatch(StateEventResponse a, StateEventResponse b) {
+public class Msc4222EmulationSyncProcessor(AuthenticatedHomeserverGeneric homeserver, ILogger? logger) {
+ private static bool StateEventsMatch(MatrixEventResponse a, MatrixEventResponse b) {
return a.Type == b.Type && a.StateKey == b.StateKey;
}
- private static bool StateEventIsNewer(StateEventResponse a, StateEventResponse b) {
+ private static bool StateEventIsNewer(MatrixEventResponse a, MatrixEventResponse b) {
return StateEventsMatch(a, b) && a.OriginServerTs < b.OriginServerTs;
}
@@ -22,12 +24,13 @@ public class Msc4222EmulationSyncProcessor(AuthenticatedHomeserverGeneric homese
resp.Rooms.Join?.Any(x => x.Value.StateAfter is { Events.Count: > 0 }) == true
|| resp.Rooms.Leave?.Any(x => x.Value.StateAfter is { Events.Count: > 0 }) == true
) {
- Console.WriteLine($"Msc4222EmulationSyncProcessor.EmulateMsc4222 determined that no emulation is needed in {sw.Elapsed}");
+ logger?.Log(sw.ElapsedMilliseconds > 100 ? LogLevel.Warning : LogLevel.Debug,
+ "Msc4222EmulationSyncProcessor.EmulateMsc4222 determined that no emulation is needed in {elapsed}", sw.Elapsed);
return resp;
}
resp = await EmulateMsc4222Internal(resp, sw);
-
+
return SimpleSyncProcessors.FillRoomIds(resp);
}
@@ -42,14 +45,17 @@ public class Msc4222EmulationSyncProcessor(AuthenticatedHomeserverGeneric homese
tasks.AddRange(resp.Rooms.Leave.Select(ProcessLeftRooms).ToList());
}
- var tasksEnum = tasks.ToAsyncEnumerable();
+ var tasksEnum = tasks.ToAsyncResultEnumerable();
await foreach (var wasModified in tasksEnum) {
if (wasModified) {
modified = true;
}
}
- Console.WriteLine($"Msc4222EmulationSyncProcessor.EmulateMsc4222 processed {resp.Rooms?.Join?.Count}/{resp.Rooms?.Leave?.Count} rooms in {sw.Elapsed} (modified: {modified})");
+ logger?.Log(sw.ElapsedMilliseconds > 100 ? LogLevel.Warning : LogLevel.Debug,
+ "Msc4222EmulationSyncProcessor.EmulateMsc4222 processed {joinCount}/{leaveCount} rooms in {elapsed} (modified: {modified})",
+ resp.Rooms?.Join?.Count ?? 0, resp.Rooms?.Leave?.Count ?? 0, sw.Elapsed, modified);
+
if (modified)
resp.Msc4222Method = SyncResponse.Msc4222SyncType.Emulated;
@@ -70,7 +76,7 @@ public class Msc4222EmulationSyncProcessor(AuthenticatedHomeserverGeneric homese
Events = []
};
- var oldState = new List<StateEventResponse>();
+ var oldState = new List<MatrixEventResponse>();
if (data.State is { Events.Count: > 0 }) {
oldState.ReplaceBy(data.State.Events, StateEventIsNewer);
}
@@ -90,7 +96,7 @@ public class Msc4222EmulationSyncProcessor(AuthenticatedHomeserverGeneric homese
}
}
catch (Exception e) {
- Console.WriteLine($"Msc4222Emulation: Failed to get timeline for room {roomId}, state may be incomplete!\n{e}");
+ logger?.LogWarning("Msc4222Emulation: Failed to get timeline for room {roomId}, state may be incomplete!\n{exception}", roomId, e);
}
}
@@ -103,12 +109,12 @@ public class Msc4222EmulationSyncProcessor(AuthenticatedHomeserverGeneric homese
// .Join(oldState, x => (x.Type, x.StateKey), y => (y.Type, y.StateKey), (x, y) => x)
.IntersectBy(oldState.Select(s => (s.Type, s.StateKey)), s => (s.Type, s.StateKey))
.ToList();
-
+
data.State = null;
return true;
}
catch (Exception e) {
- Console.WriteLine($"Msc4222Emulation: Failed to get full state for room {roomId}, state may be incomplete!\n{e}");
+ logger?.LogWarning("Msc4222Emulation: Failed to get full state for room {roomId}, state may be incomplete!\n{exception}", roomId, e);
}
var tasks = oldState
@@ -117,12 +123,13 @@ public class Msc4222EmulationSyncProcessor(AuthenticatedHomeserverGeneric homese
return await room.GetStateEventAsync(oldEvt.Type, oldEvt.StateKey!);
}
catch (Exception e) {
- Console.WriteLine($"Msc4222Emulation: Failed to get state event {oldEvt.Type}/{oldEvt.StateKey} for room {roomId}, state may be incomplete!\n{e}");
+ logger?.LogWarning("Msc4222Emulation: Failed to get state event {type}/{stateKey} for room {roomId}, state may be incomplete!\n{exception}",
+ oldEvt.Type, oldEvt.StateKey, roomId, e);
return oldEvt;
}
});
- var tasksEnum = tasks.ToAsyncEnumerable();
+ var tasksEnum = tasks.ToAsyncResultEnumerable();
await foreach (var evt in tasksEnum) {
data.StateAfter.Events.Add(evt);
}
@@ -150,10 +157,10 @@ public class Msc4222EmulationSyncProcessor(AuthenticatedHomeserverGeneric homese
return true;
}
catch (Exception e) {
- Console.WriteLine($"Msc4222Emulation: Failed to get full state for room {roomId}, state may be incomplete!\n{e}");
+ logger?.LogWarning("Msc4222Emulation: Failed to get full state for room {roomId}, state may be incomplete!\n{exception}", roomId, e);
}
- var oldState = new List<StateEventResponse>();
+ var oldState = new List<MatrixEventResponse>();
if (data.State is { Events.Count: > 0 }) {
oldState.ReplaceBy(data.State.Events, StateEventIsNewer);
}
@@ -173,7 +180,7 @@ public class Msc4222EmulationSyncProcessor(AuthenticatedHomeserverGeneric homese
}
}
catch (Exception e) {
- Console.WriteLine($"Msc4222Emulation: Failed to get timeline for room {roomId}, state may be incomplete!\n{e}");
+ logger?.LogWarning("Msc4222Emulation: Failed to get timeline for room {roomId}, state may be incomplete!\n{exception}", roomId, e);
}
}
@@ -185,12 +192,13 @@ public class Msc4222EmulationSyncProcessor(AuthenticatedHomeserverGeneric homese
return await room.GetStateEventAsync(oldEvt.Type, oldEvt.StateKey!);
}
catch (Exception e) {
- Console.WriteLine($"Msc4222Emulation: Failed to get state event {oldEvt.Type}/{oldEvt.StateKey} for room {roomId}, state may be incomplete!\n{e}");
+ logger?.LogWarning("Msc4222Emulation: Failed to get state event {type}/{stateKey} for room {roomId}, state may be incomplete!\n{exception}",
+ oldEvt.Type, oldEvt.StateKey, roomId, e);
return oldEvt;
}
});
- var tasksEnum = tasks.ToAsyncEnumerable();
+ var tasksEnum = tasks.ToAsyncResultEnumerable();
await foreach (var evt in tasksEnum) {
data.StateAfter.Events.Add(evt);
}
diff --git a/LibMatrix/Helpers/SyncStateResolver.cs b/LibMatrix/Helpers/SyncStateResolver.cs
index f111c79..17c1a41 100644
--- a/LibMatrix/Helpers/SyncStateResolver.cs
+++ b/LibMatrix/Helpers/SyncStateResolver.cs
@@ -625,7 +625,7 @@ public class SyncStateResolver(AuthenticatedHomeserverGeneric homeserver, ILogge
return oldState;
}
- private static EventList? MergeEventListBy(EventList? oldState, EventList? newState, Func<StateEventResponse, StateEventResponse, bool> comparer) {
+ private static EventList? MergeEventListBy(EventList? oldState, EventList? newState, Func<MatrixEventResponse, MatrixEventResponse, bool> comparer) {
if (newState is null) return oldState;
if (oldState is null) {
return newState;
diff --git a/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs b/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs
index 55899de..916780e 100644
--- a/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs
+++ b/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs
@@ -4,6 +4,7 @@ using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Web;
+using ArcaneLibs.Collections;
using ArcaneLibs.Extensions;
using LibMatrix.EventTypes.Spec;
using LibMatrix.EventTypes.Spec.State.RoomInfo;
@@ -13,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;
@@ -145,7 +147,7 @@ public class AuthenticatedHomeserverGeneric : RemoteHomeserver {
await Task.Delay(1000);
}
}
- }).ToAsyncEnumerable();
+ }).ToAsyncResultEnumerable();
await foreach (var result in tasks)
if (result is not null)
@@ -215,7 +217,7 @@ public class AuthenticatedHomeserverGeneric : RemoteHomeserver {
if (preserveCustomRoomProfile) {
var rooms = await GetJoinedRooms();
- var roomProfiles = rooms.Select(GetOwnRoomProfileWithIdAsync).ToAsyncEnumerable();
+ var roomProfiles = rooms.Select(GetOwnRoomProfileWithIdAsync).ToAsyncResultEnumerable();
targetSyncCount = rooms.Count;
await foreach (var (roomId, currentRoomProfile) in roomProfiles)
try {
@@ -288,14 +290,26 @@ public class AuthenticatedHomeserverGeneric : RemoteHomeserver {
public async IAsyncEnumerable<KeyValuePair<string, RoomMemberEventContent>> GetRoomProfilesAsync() {
var rooms = await GetJoinedRooms();
- var results = rooms.Select(GetOwnRoomProfileWithIdAsync).ToAsyncEnumerable();
+ var results = rooms.Select(GetOwnRoomProfileWithIdAsync).ToAsyncResultEnumerable();
await foreach (var res in results) yield return res;
}
public async Task<RoomIdResponse> JoinRoomAsync(string roomId, List<string> homeservers = null, string? reason = null) {
var joinUrl = $"/_matrix/client/v3/join/{HttpUtility.UrlEncode(roomId)}";
Console.WriteLine($"Calling {joinUrl} with {homeservers?.Count ?? 0} via's...");
- if (homeservers == null || homeservers.Count == 0) homeservers = new List<string> { roomId.Split(':')[1] };
+ if (homeservers is not { Count: > 0 }) {
+ // Legacy room IDs: !abc:server.xyz
+ if (roomId.Contains(':'))
+ homeservers = [ServerName, roomId.Split(':')[1]];
+ // v12+ room IDs: !<hash>
+ else {
+ homeservers = [ServerName];
+ foreach (var room in await GetJoinedRooms()) {
+ homeservers.Add(await room.GetOriginHomeserverAsync());
+ }
+ }
+ }
+
var fullJoinUrl = $"{joinUrl}?server_name=" + string.Join("&server_name=", homeservers);
var res = await ClientHttpClient.PostAsJsonAsync(fullJoinUrl, new {
reason
@@ -397,15 +411,12 @@ public class AuthenticatedHomeserverGeneric : RemoteHomeserver {
private Dictionary<string, string>? _namedFilterCache;
private Dictionary<string, SyncFilter> _filterCache = new();
- public async Task<JsonObject?> GetCapabilitiesAsync() {
- var res = await ClientHttpClient.GetAsync("/_matrix/client/v3/capabilities");
- if (!res.IsSuccessStatusCode) {
- Console.WriteLine($"Failed to get capabilities: {await res.Content.ReadAsStringAsync()}");
- throw new InvalidDataException($"Failed to get capabilities: {await res.Content.ReadAsStringAsync()}");
- }
+ private static readonly SemaphoreCache<CapabilitiesResponse> CapabilitiesCache = new();
- return await res.Content.ReadFromJsonAsync<JsonObject>();
- }
+ public async Task<CapabilitiesResponse> GetCapabilitiesAsync() =>
+ await CapabilitiesCache.GetOrAdd(ServerName, async () =>
+ await ClientHttpClient.GetFromJsonAsync<CapabilitiesResponse>("/_matrix/client/v3/capabilities")
+ );
public class HsNamedCaches {
internal HsNamedCaches(AuthenticatedHomeserverGeneric hs) {
@@ -429,7 +440,8 @@ public class AuthenticatedHomeserverGeneric : RemoteHomeserver {
try {
// Console.WriteLine($"Trying authenticated media URL: {uri}");
var res = await ClientHttpClient.SendAsync(new() {
- Method = HttpMethod.Head,
+ // Method = HttpMethod.Head, // This apparently doesn't work with Matrix-Media-Repo...
+ Method = HttpMethod.Get,
RequestUri = (new Uri(mxcUri.ToDownloadUri(BaseUrl, filename, timeout), string.IsNullOrWhiteSpace(BaseUrl) ? UriKind.Relative : UriKind.Absolute))
});
if (res.IsSuccessStatusCode) {
@@ -445,7 +457,8 @@ public class AuthenticatedHomeserverGeneric : RemoteHomeserver {
try {
// Console.WriteLine($"Trying legacy media URL: {uri}");
var res = await ClientHttpClient.SendAsync(new() {
- Method = HttpMethod.Head,
+ // Method = HttpMethod.Head,
+ Method = HttpMethod.Get,
RequestUri = new(mxcUri.ToLegacyDownloadUri(BaseUrl, filename, timeout), string.IsNullOrWhiteSpace(BaseUrl) ? UriKind.Relative : UriKind.Absolute)
});
if (res.IsSuccessStatusCode) {
@@ -574,8 +587,87 @@ public class AuthenticatedHomeserverGeneric : RemoteHomeserver {
await SetAccountDataAsync(IgnoredUserListEventContent.EventId, ignoredUserList);
}
- private class CapabilitiesResponse {
+ public class CapabilitiesResponse {
[JsonPropertyName("capabilities")]
- public Dictionary<string, object>? Capabilities { get; set; }
+ public CapabilitiesContents Capabilities { get; set; }
+
+ public class CapabilitiesContents {
+ [JsonPropertyName("m.3pid_changes")]
+ public BooleanCapability? ThreePidChanges { get; set; }
+
+ [JsonPropertyName("m.change_password")]
+ public BooleanCapability? ChangePassword { get; set; }
+
+ [JsonPropertyName("m.get_login_token")]
+ public BooleanCapability? GetLoginToken { get; set; }
+
+ [JsonPropertyName("m.room_versions")]
+ public RoomVersionsCapability? RoomVersions { get; set; }
+
+ [JsonPropertyName("m.set_avatar_url")]
+ public BooleanCapability? SetAvatarUrl { get; set; }
+
+ [JsonPropertyName("m.set_displayname")]
+ public BooleanCapability? SetDisplayName { get; set; }
+
+ [JsonPropertyName("gay.rory.bulk_send_events")]
+ public BooleanCapability? BulkSendEvents { get; set; }
+
+ [JsonPropertyName("gay.rory.synapse_admin_extensions.room_list.query_events.v2")]
+ public BooleanCapability? SynapseRoomListQueryEventsV2 { get; set; }
+
+ [JsonExtensionData]
+ public Dictionary<string, object>? AdditionalCapabilities { get; set; }
+ }
+
+ public class BooleanCapability {
+ [JsonPropertyName("enabled")]
+ public bool Enabled { get; set; }
+ }
+
+ public class RoomVersionsCapability {
+ [JsonPropertyName("default")]
+ public string? Default { get; set; }
+
+ [JsonPropertyName("available")]
+ public Dictionary<string, string>? Available { get; set; }
+ }
+ }
+
+#region Room Directory/aliases
+
+ public async Task SetRoomAliasAsync(string roomAlias, string roomId) {
+ var resp = await ClientHttpClient.PutAsJsonAsync($"/_matrix/client/v3/directory/room/{HttpUtility.UrlEncode(roomAlias)}", new RoomIdResponse() {
+ RoomId = roomId
+ });
+ if (!resp.IsSuccessStatusCode) {
+ Console.WriteLine($"Failed to set room alias: {await resp.Content.ReadAsStringAsync()}");
+ throw new InvalidDataException($"Failed to set room alias: {await resp.Content.ReadAsStringAsync()}");
+ }
}
+
+ public async Task DeleteRoomAliasAsync(string roomAlias) {
+ var resp = await ClientHttpClient.DeleteAsync("/_matrix/client/v3/directory/room/" + HttpUtility.UrlEncode(roomAlias));
+ if (!resp.IsSuccessStatusCode) {
+ Console.WriteLine($"Failed to set room alias: {await resp.Content.ReadAsStringAsync()}");
+ throw new InvalidDataException($"Failed to set room alias: {await resp.Content.ReadAsStringAsync()}");
+ }
+ }
+
+ public async Task<RoomAliasesResponse> GetLocalRoomAliasesAsync(string roomId) {
+ var resp = await ClientHttpClient.GetAsync($"/_matrix/client/v3/rooms/{HttpUtility.UrlEncode(roomId)}/aliases");
+ if (!resp.IsSuccessStatusCode) {
+ Console.WriteLine($"Failed to get room aliases: {await resp.Content.ReadAsStringAsync()}");
+ throw new InvalidDataException($"Failed to get room aliases: {await resp.Content.ReadAsStringAsync()}");
+ }
+
+ return await resp.Content.ReadFromJsonAsync<RoomAliasesResponse>() ?? throw new Exception("Failed to get room aliases?");
+ }
+
+ public class RoomAliasesResponse {
+ [JsonPropertyName("aliases")]
+ public required List<string> Aliases { get; set; }
+ }
+
+#endregion
}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/FederationClient.cs b/LibMatrix/Homeservers/FederationClient.cs
index 617b737..9760e20 100644
--- a/LibMatrix/Homeservers/FederationClient.cs
+++ b/LibMatrix/Homeservers/FederationClient.cs
@@ -1,5 +1,5 @@
-using System.Text.Json.Serialization;
using LibMatrix.Extensions;
+using LibMatrix.Responses.Federation;
using LibMatrix.Services;
namespace LibMatrix.Homeservers;
@@ -17,17 +17,7 @@ public class FederationClient {
public HomeserverResolverService.WellKnownUris WellKnownUris { get; set; }
public async Task<ServerVersionResponse> GetServerVersionAsync() => await HttpClient.GetFromJsonAsync<ServerVersionResponse>("/_matrix/federation/v1/version");
+ public async Task<SignedObject<ServerKeysResponse>> GetServerKeysAsync() => await HttpClient.GetFromJsonAsync<SignedObject<ServerKeysResponse>>("/_matrix/key/v2/server");
}
-public class ServerVersionResponse {
- [JsonPropertyName("server")]
- public required ServerInfo Server { get; set; }
- public class ServerInfo {
- [JsonPropertyName("name")]
- public string Name { get; set; }
-
- [JsonPropertyName("version")]
- public string Version { get; set; }
- }
-}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalRoomQueryFilter.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalRoomQueryFilter.cs
index b8929a0..97c4bbf 100644
--- a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalRoomQueryFilter.cs
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalRoomQueryFilter.cs
@@ -1,27 +1,90 @@
namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Filters;
public class SynapseAdminLocalRoomQueryFilter {
- public string RoomIdContains { get; set; } = "";
- public string NameContains { get; set; } = "";
- public string CanonicalAliasContains { get; set; } = "";
- public string VersionContains { get; set; } = "";
- public string CreatorContains { get; set; } = "";
- public string EncryptionContains { get; set; } = "";
- public string JoinRulesContains { get; set; } = "";
- public string GuestAccessContains { get; set; } = "";
- public string HistoryVisibilityContains { get; set; } = "";
-
- public bool Federatable { get; set; } = true;
- public bool Public { get; set; } = true;
-
- public int JoinedMembersGreaterThan { get; set; }
- public int JoinedMembersLessThan { get; set; } = int.MaxValue;
-
- public int JoinedLocalMembersGreaterThan { get; set; }
- public int JoinedLocalMembersLessThan { get; set; } = int.MaxValue;
- public int StateEventsGreaterThan { get; set; }
- public int StateEventsLessThan { get; set; } = int.MaxValue;
-
- public bool CheckFederation { get; set; }
- public bool CheckPublic { get; set; }
+ public StringFilter RoomId { get; set; } = new();
+ public StringFilter Name { get; set; } = new();
+ public StringFilter CanonicalAlias { get; set; } = new();
+ public StringFilter Version { get; set; } = new();
+ public StringFilter Creator { get; set; } = new();
+ public StringFilter Encryption { get; set; } = new();
+ public StringFilter JoinRules { get; set; } = new();
+ public StringFilter GuestAccess { get; set; } = new();
+ public StringFilter HistoryVisibility { get; set; } = new();
+ public StringFilter RoomType { get; set; } = new();
+ public StringFilter Topic { get; set; } = new();
+
+ public IntFilter JoinedMembers { get; set; } = new() {
+ GreaterThan = 0,
+ LessThan = int.MaxValue
+ };
+
+ public IntFilter JoinedLocalMembers { get; set; } = new() {
+ GreaterThan = 0,
+ LessThan = int.MaxValue
+ };
+
+ public IntFilter StateEvents { get; set; } = new() {
+ GreaterThan = 0,
+ LessThan = int.MaxValue
+ };
+
+ public BoolFilter Federation { get; set; } = new();
+ public BoolFilter Public { get; set; } = new();
+ public BoolFilter Tombstone { get; set; } = new();
+}
+
+public class OptionalFilter {
+ public bool Enabled { get; set; }
+}
+
+public class StringFilter : OptionalFilter {
+ public bool CheckValueContains { get; set; }
+ public string? ValueContains { get; set; }
+
+ public bool CheckValueEquals { get; set; }
+ public string? ValueEquals { get; set; }
+
+ public bool Matches(string? value, StringComparison comparison = StringComparison.Ordinal) {
+ if (!Enabled) return true;
+
+ if (CheckValueEquals) {
+ if (!string.Equals(value, ValueEquals, comparison)) return false;
+ }
+
+ if (CheckValueContains && ValueContains != null) {
+ if (value != null && !value.Contains(ValueContains, comparison)) return false;
+ }
+
+ return true;
+ }
+}
+
+public class IntFilter : OptionalFilter {
+ public bool CheckGreaterThan { get; set; }
+ public int GreaterThan { get; set; }
+ public bool CheckLessThan { get; set; }
+ public int LessThan { get; set; }
+
+ public bool Matches(int value) {
+ if (!Enabled) return true;
+
+ if (CheckGreaterThan) {
+ if (value <= GreaterThan) return false;
+ }
+
+ if (CheckLessThan) {
+ if (value >= LessThan) return false;
+ }
+
+ return true;
+ }
+}
+
+public class BoolFilter : OptionalFilter {
+ public bool Value { get; set; }
+
+ public bool Matches(bool value) {
+ if (!Enabled) return true;
+ return value == Value;
+ }
}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalUserQueryFilter.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalUserQueryFilter.cs
index 62b291b..5a4acf7 100644
--- a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalUserQueryFilter.cs
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalUserQueryFilter.cs
@@ -1,27 +1,5 @@
namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Filters;
public class SynapseAdminLocalUserQueryFilter {
- public string UserIdContains { get; set; } = "";
- public string NameContains { get; set; } = "";
- public string CanonicalAliasContains { get; set; } = "";
- public string VersionContains { get; set; } = "";
- public string CreatorContains { get; set; } = "";
- public string EncryptionContains { get; set; } = "";
- public string JoinRulesContains { get; set; } = "";
- public string GuestAccessContains { get; set; } = "";
- public string HistoryVisibilityContains { get; set; } = "";
- public bool Federatable { get; set; } = true;
- public bool Public { get; set; } = true;
-
- public int JoinedMembersGreaterThan { get; set; }
- public int JoinedMembersLessThan { get; set; } = int.MaxValue;
-
- public int JoinedLocalMembersGreaterThan { get; set; }
- public int JoinedLocalMembersLessThan { get; set; } = int.MaxValue;
- public int StateEventsGreaterThan { get; set; }
- public int StateEventsLessThan { get; set; } = int.MaxValue;
-
- public bool CheckFederation { get; set; }
- public bool CheckPublic { get; set; }
}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/EventReportListResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/EventReportListResult.cs
index 10fc039..0f3ee56 100644
--- a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/EventReportListResult.cs
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/EventReportListResult.cs
@@ -97,10 +97,10 @@ public class SynapseAdminEventReportListResult : SynapseNextTokenTotalCollection
[JsonPropertyName("unsigned")]
public JsonObject? Unsigned { get; set; }
- // Extra... copied from StateEventResponse
+ // Extra... copied from MatrixEventResponse
[JsonIgnore]
- public Type MappedType => StateEvent.GetStateEventType(Type);
+ public Type MappedType => MatrixEvent.GetEventType(Type);
[JsonIgnore]
public bool IsLegacyType => MappedType.GetCustomAttributes<MatrixEventAttribute>().FirstOrDefault(x => x.EventName == Type)?.Legacy ?? false;
@@ -128,7 +128,7 @@ public class SynapseAdminEventReportListResult : SynapseNextTokenTotalCollection
// return null;
// }
try {
- var mappedType = StateEvent.GetStateEventType(Type);
+ var mappedType = MatrixEvent.GetEventType(Type);
if (mappedType == typeof(UnknownEventContent))
Console.WriteLine($"Warning: unknown event type '{Type}'");
var deserialisedContent = (EventContent)RawContent.Deserialize(mappedType, TypedContentSerializerOptions)!;
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RoomListResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RoomListResult.cs
index d84c89b..7006c07 100644
--- a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RoomListResult.cs
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RoomListResult.cs
@@ -1,4 +1,5 @@
using System.Text.Json.Serialization;
+using LibMatrix.EventTypes.Spec.State.RoomInfo;
namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
@@ -60,5 +61,56 @@ public class SynapseAdminRoomListResult {
[JsonPropertyName("state_events")]
public int StateEvents { get; set; }
+
+ [JsonPropertyName("gay.rory.synapse_admin_extensions.tombstone")]
+ public MatrixEventResponse? TombstoneEvent { get; set; }
+
+ [JsonPropertyName("gay.rory.synapse_admin_extensions.create")]
+ public MatrixEventResponse? CreateEvent { get; set; }
+
+ [JsonPropertyName("gay.rory.synapse_admin_extensions.topic")]
+ public MatrixEventResponse? TopicEvent { get; set; }
+
+ public async Task<MatrixEventResponse?> GetCreateEventAsync(AuthenticatedHomeserverSynapse hs) {
+ if (CreateEvent != null) return CreateEvent;
+
+ try {
+ var events = (await hs.Admin.GetRoomStateAsync(RoomId, RoomCreateEventContent.EventId));
+ CreateEvent = events.Events.SingleOrDefault(x => x.StateKey == "");
+ }
+ catch (Exception e) {
+ Console.WriteLine($"Failed to fetch room create event for {RoomId}: {e}");
+ }
+
+ return null;
+ }
+
+ public async Task<MatrixEventResponse?> GetTombstoneEventAsync(AuthenticatedHomeserverSynapse hs) {
+ if (TombstoneEvent != null) return TombstoneEvent;
+
+ try {
+ var events = (await hs.Admin.GetRoomStateAsync(RoomId, RoomTombstoneEventContent.EventId));
+ TombstoneEvent = events.Events.SingleOrDefault(x => x.StateKey == "");
+ }
+ catch (Exception e) {
+ Console.WriteLine($"Failed to fetch room tombstone event for {RoomId}: {e}");
+ }
+
+ return null;
+ }
+
+ public async Task<MatrixEventResponse?> GetTopicEventAsync(AuthenticatedHomeserverSynapse hs) {
+ if (TopicEvent != null) return TopicEvent;
+
+ try {
+ var events = await hs.Admin.GetRoomStateAsync(RoomId, RoomTopicEventContent.EventId);
+ TopicEvent = events.Events.SingleOrDefault(x => x.StateKey == "");
+ }
+ catch (Exception e) {
+ Console.WriteLine($"Failed to fetch room topic event for {RoomId}: {e}");
+ }
+
+ return null;
+ }
}
}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminRoomStateResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminRoomStateResult.cs
index ae36d4e..d9d5f1a 100644
--- a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminRoomStateResult.cs
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminRoomStateResult.cs
@@ -4,5 +4,5 @@ namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
public class SynapseAdminRoomStateResult {
[JsonPropertyName("state")]
- public required List<StateEventResponse> Events { get; set; }
+ public required List<MatrixEventResponse> Events { get; set; }
}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminApiClient.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminApiClient.cs
index 777c04a..f839e20 100644
--- a/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminApiClient.cs
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminApiClient.cs
@@ -1,21 +1,16 @@
// #define LOG_SKIP
+using System.CodeDom.Compiler;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
-using System.Net.Http.Json;
-using System.Text.Json;
-using System.Text.Json.Nodes;
-using System.Text.Json.Serialization;
using ArcaneLibs.Extensions;
+using LibMatrix.EventTypes.Spec.State.RoomInfo;
using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Filters;
using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Requests;
using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
using LibMatrix.Responses;
-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;
@@ -27,24 +22,41 @@ public class SynapseAdminApiClient(AuthenticatedHomeserverSynapse authenticatedH
#region Rooms
public async IAsyncEnumerable<SynapseAdminRoomListResult.SynapseAdminRoomListResultRoom> SearchRoomsAsync(int limit = int.MaxValue, int chunkLimit = 250,
- string orderBy = "name", string dir = "f", string? searchTerm = null, SynapseAdminLocalRoomQueryFilter? localFilter = null) {
+ string orderBy = "name", string dir = "f", string? searchTerm = null, SynapseAdminLocalRoomQueryFilter? localFilter = null,
+ bool fetchTombstones = false, bool fetchTopics = false, bool fetchCreateEvents = false) {
+ if (localFilter != null) {
+ fetchTombstones |= localFilter.Tombstone.Enabled;
+ fetchTopics |= localFilter.Topic.Enabled;
+ fetchCreateEvents |= localFilter.RoomType.Enabled;
+ }
+
+ var serverCaps = await authenticatedHomeserver.GetCapabilitiesAsync();
+ var serverSupportsQueryEventsV2 = serverCaps.Capabilities.SynapseRoomListQueryEventsV2?.Enabled ?? false;
+
SynapseAdminRoomListResult? res = null;
var i = 0;
int? totalRooms = null;
do {
var url = $"/_synapse/admin/v1/rooms?limit={Math.Min(limit, chunkLimit)}&dir={dir}&order_by={orderBy}";
- if (!string.IsNullOrEmpty(searchTerm)) url += $"&search_term={searchTerm}";
+ if (!string.IsNullOrEmpty(searchTerm)) url += $"&search_term={searchTerm}";
if (res?.NextBatch is not null) url += $"&from={res.NextBatch}";
+ // nonstandard stuff
+ if (fetchTombstones) url += "&gay.rory.synapse_admin_extensions.include_tombstone=true&emma_include_tombstone=true";
+ if (fetchTopics) url += "&gay.rory.synapse_admin_extensions.include_topic=true";
+ if (fetchCreateEvents) url += "&gay.rory.synapse_admin_extensions.include_create_event=true";
+
Console.WriteLine($"--- ADMIN Querying Room List with URL: {url} - Already have {i} items... ---");
res = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRoomListResult>(url);
totalRooms ??= res.TotalRooms;
// Console.WriteLine(res.ToJson(false));
+
+ List<SynapseAdminRoomListResult.SynapseAdminRoomListResultRoom> keep = [];
foreach (var room in res.Rooms) {
if (localFilter is not null) {
- if (!string.IsNullOrWhiteSpace(localFilter.RoomIdContains) && !room.RoomId.Contains(localFilter.RoomIdContains, StringComparison.OrdinalIgnoreCase)) {
+ if (!localFilter.RoomId.Matches(room.RoomId, StringComparison.OrdinalIgnoreCase)) {
totalRooms--;
#if LOG_SKIP
Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule roomid.");
@@ -52,7 +64,7 @@ public class SynapseAdminApiClient(AuthenticatedHomeserverSynapse authenticatedH
continue;
}
- if (!string.IsNullOrWhiteSpace(localFilter.NameContains) && room.Name?.Contains(localFilter.NameContains, StringComparison.OrdinalIgnoreCase) != true) {
+ if (!localFilter.Name.Matches(room.Name ?? "", StringComparison.OrdinalIgnoreCase)) {
totalRooms--;
#if LOG_SKIP
Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule roomname.");
@@ -60,8 +72,7 @@ public class SynapseAdminApiClient(AuthenticatedHomeserverSynapse authenticatedH
continue;
}
- if (!string.IsNullOrWhiteSpace(localFilter.CanonicalAliasContains) &&
- room.CanonicalAlias?.Contains(localFilter.CanonicalAliasContains, StringComparison.OrdinalIgnoreCase) != true) {
+ if (!localFilter.CanonicalAlias.Matches(room.CanonicalAlias ?? "", StringComparison.OrdinalIgnoreCase)) {
totalRooms--;
#if LOG_SKIP
Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule alias.");
@@ -69,7 +80,7 @@ public class SynapseAdminApiClient(AuthenticatedHomeserverSynapse authenticatedH
continue;
}
- if (!string.IsNullOrWhiteSpace(localFilter.VersionContains) && !room.Version.Contains(localFilter.VersionContains, StringComparison.OrdinalIgnoreCase)) {
+ if (!localFilter.Version.Matches(room.Version ?? "")) {
totalRooms--;
#if LOG_SKIP
Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule version.");
@@ -77,7 +88,7 @@ public class SynapseAdminApiClient(AuthenticatedHomeserverSynapse authenticatedH
continue;
}
- if (!string.IsNullOrWhiteSpace(localFilter.CreatorContains) && !room.Creator.Contains(localFilter.CreatorContains, StringComparison.OrdinalIgnoreCase)) {
+ if (!localFilter.Creator.Matches(room.Creator ?? "", StringComparison.OrdinalIgnoreCase)) {
totalRooms--;
#if LOG_SKIP
Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule creator.");
@@ -85,8 +96,7 @@ public class SynapseAdminApiClient(AuthenticatedHomeserverSynapse authenticatedH
continue;
}
- if (!string.IsNullOrWhiteSpace(localFilter.EncryptionContains) &&
- room.Encryption?.Contains(localFilter.EncryptionContains, StringComparison.OrdinalIgnoreCase) != true) {
+ if (!localFilter.Encryption.Matches(room.Encryption ?? "", StringComparison.OrdinalIgnoreCase)) {
totalRooms--;
#if LOG_SKIP
Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule encryption.");
@@ -94,8 +104,7 @@ public class SynapseAdminApiClient(AuthenticatedHomeserverSynapse authenticatedH
continue;
}
- if (!string.IsNullOrWhiteSpace(localFilter.JoinRulesContains) &&
- room.JoinRules?.Contains(localFilter.JoinRulesContains, StringComparison.OrdinalIgnoreCase) != true) {
+ if (!localFilter.JoinRules.Matches(room.JoinRules ?? "", StringComparison.OrdinalIgnoreCase)) {
totalRooms--;
#if LOG_SKIP
Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule joinrules.");
@@ -103,8 +112,7 @@ public class SynapseAdminApiClient(AuthenticatedHomeserverSynapse authenticatedH
continue;
}
- if (!string.IsNullOrWhiteSpace(localFilter.GuestAccessContains) &&
- room.GuestAccess?.Contains(localFilter.GuestAccessContains, StringComparison.OrdinalIgnoreCase) != true) {
+ if (!localFilter.GuestAccess.Matches(room.GuestAccess ?? "", StringComparison.OrdinalIgnoreCase)) {
totalRooms--;
#if LOG_SKIP
Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule guestaccess.");
@@ -112,8 +120,7 @@ public class SynapseAdminApiClient(AuthenticatedHomeserverSynapse authenticatedH
continue;
}
- if (!string.IsNullOrWhiteSpace(localFilter.HistoryVisibilityContains) &&
- room.HistoryVisibility?.Contains(localFilter.HistoryVisibilityContains, StringComparison.OrdinalIgnoreCase) != true) {
+ if (!localFilter.HistoryVisibility.Matches(room.HistoryVisibility ?? "", StringComparison.OrdinalIgnoreCase)) {
totalRooms--;
#if LOG_SKIP
Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule history visibility.");
@@ -121,7 +128,7 @@ public class SynapseAdminApiClient(AuthenticatedHomeserverSynapse authenticatedH
continue;
}
- if (localFilter.CheckFederation && room.Federatable != localFilter.Federatable) {
+ if (!localFilter.Federation.Matches(room.Federatable)) {
totalRooms--;
#if LOG_SKIP
Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule federation.");
@@ -129,7 +136,7 @@ public class SynapseAdminApiClient(AuthenticatedHomeserverSynapse authenticatedH
continue;
}
- if (localFilter.CheckPublic && room.Public != localFilter.Public) {
+ if (!localFilter.Public.Matches(room.Public)) {
totalRooms--;
#if LOG_SKIP
Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule public.");
@@ -137,15 +144,15 @@ public class SynapseAdminApiClient(AuthenticatedHomeserverSynapse authenticatedH
continue;
}
- if (room.StateEvents < localFilter.StateEventsGreaterThan || room.StateEvents > localFilter.StateEventsLessThan) {
+ if (!localFilter.StateEvents.Matches(room.StateEvents)) {
totalRooms--;
#if LOG_SKIP
- Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule joined local members.");
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule state events.");
#endif
continue;
}
- if (room.JoinedMembers < localFilter.JoinedMembersGreaterThan || room.JoinedMembers > localFilter.JoinedMembersLessThan) {
+ if (!localFilter.JoinedMembers.Matches(room.JoinedMembers)) {
totalRooms--;
#if LOG_SKIP
Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule joined members: {localFilter.JoinedMembersGreaterThan} < {room.JoinedLocalMembers} < {localFilter.JoinedMembersLessThan}.");
@@ -153,7 +160,7 @@ public class SynapseAdminApiClient(AuthenticatedHomeserverSynapse authenticatedH
continue;
}
- if (room.JoinedLocalMembers < localFilter.JoinedLocalMembersGreaterThan || room.JoinedLocalMembers > localFilter.JoinedLocalMembersLessThan) {
+ if (!localFilter.JoinedLocalMembers.Matches(room.JoinedLocalMembers)) {
totalRooms--;
#if LOG_SKIP
Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule joined local members: {localFilter.JoinedLocalMembersGreaterThan} < {room.JoinedLocalMembers} < {localFilter.JoinedLocalMembersLessThan}.");
@@ -161,28 +168,99 @@ public class SynapseAdminApiClient(AuthenticatedHomeserverSynapse authenticatedH
continue;
}
}
- // if (contentSearch is not null && !string.IsNullOrEmpty(contentSearch) &&
- // !(
- // room.Name?.Contains(contentSearch, StringComparison.InvariantCultureIgnoreCase) == true ||
- // room.CanonicalAlias?.Contains(contentSearch, StringComparison.InvariantCultureIgnoreCase) == true ||
- // room.Creator?.Contains(contentSearch, StringComparison.InvariantCultureIgnoreCase) == true
- // )
- // ) {
- // totalRooms--;
- // continue;
- // }
i++;
+ keep.Add(room);
+ }
+
+ var parallelisationLimit = new SemaphoreSlim(32, 32);
+ List<Task<(SynapseAdminRoomListResult.SynapseAdminRoomListResultRoom room, MatrixEventResponse?[] tasks)>> tasks = [];
+
+ async Task<(SynapseAdminRoomListResult.SynapseAdminRoomListResultRoom room, MatrixEventResponse?[] tasks)> fillTask(
+ SynapseAdminRoomListResult.SynapseAdminRoomListResultRoom room) {
+ if (serverSupportsQueryEventsV2) return (room, []);
+
+ var fillTasks = await Task.WhenAll(((Task<MatrixEventResponse?>?[]) [
+ fetchTombstones && room.TombstoneEvent is null
+ ? parallelisationLimit.RunWithLockAsync(() => room.GetTombstoneEventAsync(authenticatedHomeserver))
+ : null!,
+ fetchTopics && room.TopicEvent is null
+ ? parallelisationLimit.RunWithLockAsync(() => room.GetTopicEventAsync(authenticatedHomeserver))
+ : null!,
+ fetchCreateEvents && room.CreateEvent is null
+ ? parallelisationLimit.RunWithLockAsync(() => room.GetCreateEventAsync(authenticatedHomeserver))
+ : null!,
+ ])
+ .Where(t => t != null)!
+ );
+ return (
+ room,
+ fillTasks
+ );
+ }
+
+ tasks.AddRange(
+ serverSupportsQueryEventsV2
+ ? keep.Select(x => Task.FromResult((x, (MatrixEventResponse?[])[])))
+ : keep.Select(fillTask)
+ );
+
+ // await Task.WhenAll(tasks);
+
+ foreach (var taskRes in tasks) {
+ var (room, _) = await taskRes;
+ if (localFilter is not null) {
+ if (!localFilter.Tombstone.Matches(room.TombstoneEvent != null)) {
+ totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule tombstone.");
+#endif
+ continue;
+ }
+
+ if (!localFilter.RoomType.Matches(room.CreateEvent?.ContentAs<RoomCreateEventContent>()?.Type)) {
+ totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule room type.");
+#endif
+ continue;
+ }
+
+ if (!localFilter.Topic.Matches(room.TopicEvent?.ContentAs<RoomTopicEventContent>()?.Topic, StringComparison.OrdinalIgnoreCase)) {
+ totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule topic.");
+#endif
+ continue;
+ }
+ }
+
yield return room;
}
} while (i < Math.Min(limit, totalRooms ?? limit));
}
+ public async Task<bool> CheckRoomKnownAsync(string roomId) {
+ try {
+ var createEvt = await GetRoomStateAsync(roomId, RoomCreateEventContent.EventId);
+ if (createEvt.Events.FirstOrDefault(e => e.StateKey == "") is null)
+ return false;
+ var members = await GetRoomMembersAsync(roomId, localOnly: true);
+ return members.Members.Count > 0;
+ }
+ catch (Exception e) {
+ if (e is HttpRequestException { StatusCode: System.Net.HttpStatusCode.NotFound }) return false;
+ if (e is MatrixException { ErrorCode: MatrixException.ErrorCodes.M_NOT_FOUND }) return false;
+ throw;
+ }
+ }
+
#endregion
#region Users
public async IAsyncEnumerable<SynapseAdminUserListResult.SynapseAdminUserListResultUser> SearchUsersAsync(int limit = int.MaxValue, int chunkLimit = 250,
+ string orderBy = "name", string dir = "f",
SynapseAdminLocalUserQueryFilter? localFilter = null) {
// TODO: implement filters
string? from = null;
@@ -190,6 +268,9 @@ public class SynapseAdminApiClient(AuthenticatedHomeserverSynapse authenticatedH
var url = new Uri("/_synapse/admin/v3/users", UriKind.Relative);
url = url.AddQuery("limit", Math.Min(limit, chunkLimit).ToString());
if (!string.IsNullOrWhiteSpace(from)) url = url.AddQuery("from", from);
+ if (!string.IsNullOrWhiteSpace(orderBy)) url = url.AddQuery("order_by", orderBy);
+ if (!string.IsNullOrWhiteSpace(dir)) url = url.AddQuery("dir", dir);
+
Console.WriteLine($"--- ADMIN Querying User List with URL: {url} ---");
// TODO: implement URI methods in http client
var res = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminUserListResult>(url.ToString());
@@ -522,8 +603,13 @@ public class SynapseAdminApiClient(AuthenticatedHomeserverSynapse authenticatedH
$"/_synapse/admin/v2/rooms/delete_status/{deleteId}");
}
- public async Task<SynapseAdminRoomMemberListResult> GetRoomMembersAsync(string roomId) {
- return await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRoomMemberListResult>($"/_synapse/admin/v1/rooms/{roomId.UrlEncode()}/members");
+ public async Task<SynapseAdminRoomMemberListResult> GetRoomMembersAsync(string roomId, bool localOnly = false) {
+ var res = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRoomMemberListResult>($"/_synapse/admin/v1/rooms/{roomId.UrlEncode()}/members");
+ if (localOnly) {
+ res.Members = res.Members.Where(m => m.EndsWith($":{authenticatedHomeserver.ServerName}")).ToList();
+ }
+
+ return res;
}
public async Task<SynapseAdminRoomStateResult> GetRoomStateAsync(string roomId, string? type = null) {
diff --git a/LibMatrix/Homeservers/RemoteHomeServer.cs b/LibMatrix/Homeservers/RemoteHomeServer.cs
index f0b35f9..af84be2 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<string, object> _profileCache { get; set; } = new();
+ // private Dictionary<string, object> _profileCache { get; set; } = new();
+ private SemaphoreCache<UserProfileResponse> _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<UserProfileResponse> 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<UserProfileResponse>();
- 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<UserProfileResponse> GetProfileAsync(string mxid) =>
+ await ClientHttpClient.GetFromJsonAsync<UserProfileResponse>($"/_matrix/client/v3/profile/{HttpUtility.UrlEncode(mxid)}");
public async Task<ClientVersionsResponse> GetClientVersionsAsync() {
- var resp = await ClientHttpClient.GetAsync($"/_matrix/client/versions");
+ var resp = await ClientHttpClient.GetAsync("/_matrix/client/versions");
var data = await resp.Content.ReadFromJsonAsync<ClientVersionsResponse>();
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<PublicRoomDirectoryResult> GetPublicRoomsAsync(int limit = 100, string? server = null, string? since = null) =>
- ClientHttpClient.GetFromJsonAsync<PublicRoomDirectoryResult>(buildUriWithParams("/_matrix/client/v3/publicRooms", (nameof(limit), true, limit),
- (nameof(server), !string.IsNullOrWhiteSpace(server), server), (nameof(since), !string.IsNullOrWhiteSpace(since), since)));
+ public Task<PublicRoomDirectoryResult> 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}";
+ }
- // 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));
+ if (!string.IsNullOrWhiteSpace(since)) {
+ url += $"&since={since}";
+ }
+
+ return ClientHttpClient.GetFromJsonAsync<PublicRoomDirectoryResult>(url);
+ }
+
+ public async IAsyncEnumerable<PublicRoomDirectoryResult> 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
@@ -117,9 +118,6 @@ public class RemoteHomeserver {
#endregion
- [Obsolete("This call uses the deprecated unauthenticated media endpoints, please switch to the relevant AuthenticatedHomeserver methods instead.", true)]
- public virtual string? ResolveMediaUri(string? mxcUri) => null;
-
public UserInteractiveAuthClient Auth;
}
diff --git a/LibMatrix/LibMatrix.csproj b/LibMatrix/LibMatrix.csproj
index 62bb48f..7d4ca5c 100644
--- a/LibMatrix/LibMatrix.csproj
+++ b/LibMatrix/LibMatrix.csproj
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
- <TargetFramework>net9.0</TargetFramework>
+ <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
@@ -12,15 +12,16 @@
</PropertyGroup>
<ItemGroup>
- <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.1"/>
- <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.1"/>
+ <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107"/>
+ <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107"/>
<ProjectReference Include="..\LibMatrix.EventTypes\LibMatrix.EventTypes.csproj"/>
</ItemGroup>
<ItemGroup>
- <!-- <PackageReference Include="ArcaneLibs" Version="1.0.0-preview.20250313-104848" Condition="'$(Configuration)' == 'Release'" />-->
- <!-- <ProjectReference Include="..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj" Condition="'$(Configuration)' == 'Debug'"/>-->
+ <!-- <PackageReference Include="ArcaneLibs" Version="1.0.0-preview.20250313-104848" Condition="'$(Configuration)' == 'Release'" />-->
+ <!-- <ProjectReference Include="..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj" Condition="'$(Configuration)' == 'Debug'"/>-->
<ProjectReference Include="..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj"/>
+ <PackageReference Include="ArcaneLibs" Version="*-*" Condition="'$(ContinuousIntegrationBuild)'=='true'"/>
</ItemGroup>
</Project>
diff --git a/LibMatrix/LibMatrixNetworkException.cs b/LibMatrix/LibMatrixNetworkException.cs
new file mode 100644
index 0000000..7be0f4e
--- /dev/null
+++ b/LibMatrix/LibMatrixNetworkException.cs
@@ -0,0 +1,33 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Serialization;
+using ArcaneLibs.Extensions;
+// ReSharper disable MemberCanBePrivate.Global
+
+namespace LibMatrix;
+
+public class LibMatrixNetworkException : Exception {
+ public LibMatrixNetworkException() : base() { }
+ public LibMatrixNetworkException(Exception httpRequestException) : base("A network error occurred", httpRequestException) { }
+
+ [JsonPropertyName("errcode")]
+ public required string ErrorCode { get; set; }
+
+ [JsonPropertyName("error")]
+ public required string Error { get; set; }
+
+ public object GetAsObject() => new { errcode = ErrorCode, error = Error };
+ public string GetAsJson() => GetAsObject().ToJson(ignoreNull: true);
+
+ public override string Message =>
+ $"{ErrorCode}: {ErrorCode switch {
+ ErrorCodes.RLM_NET_UNKNOWN_HOST => "The specified host could not be found.",
+ ErrorCodes.RLM_NET_INVALID_REMOTE_CERTIFICATE => "The remote server's TLS certificate is invalid or could not be verified.",
+ _ => $"Unknown error: {GetAsObject().ToJson(ignoreNull: true)}"
+ }}\nError: {Error}";
+
+ [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Follows spec naming")]
+ public static class ErrorCodes {
+ public const string RLM_NET_UNKNOWN_HOST = "RLM_NET_UNKNOWN_HOST";
+ public const string RLM_NET_INVALID_REMOTE_CERTIFICATE = "RLM_NET_INVALID_REMOTE_CERTIFICATE";
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Responses/CreateRoomRequest.cs b/LibMatrix/Responses/CreateRoomRequest.cs
index d9a6acd..b4dcc78 100644
--- a/LibMatrix/Responses/CreateRoomRequest.cs
+++ b/LibMatrix/Responses/CreateRoomRequest.cs
@@ -29,7 +29,7 @@ public class CreateRoomRequest {
// public string Preset { get; set; }
[JsonPropertyName("initial_state")]
- public List<StateEvent>? InitialState { get; set; }
+ public List<MatrixEvent>? InitialState { get; set; }
/// <summary>
/// One of: ["public", "private"]
@@ -42,24 +42,27 @@ public class CreateRoomRequest {
public RoomPowerLevelEventContent? PowerLevelContentOverride { get; set; }
[JsonPropertyName("creation_content")]
- public JsonObject CreationContent { get; set; } = new();
+ public Dictionary<string, object> CreationContent { get; set; } = new();
[JsonPropertyName("invite")]
public List<string>? Invite { get; set; }
+ [JsonPropertyName("room_version")]
+ public string? RoomVersion { get; set; }
+
/// <summary>
/// For use only when you can't use the CreationContent property
/// </summary>
- public StateEvent? this[string eventType, string eventKey = ""] {
+ public MatrixEvent? this[string eventType, string eventKey = ""] {
get {
var stateEvent = InitialState?.FirstOrDefault(x => x.Type == eventType && x.StateKey == eventKey);
if (stateEvent == null)
- InitialState?.Add(stateEvent = new StateEvent {
+ InitialState?.Add(stateEvent = new MatrixEvent {
Type = eventType,
StateKey = eventKey,
TypedContent = (EventContent)Activator.CreateInstance(
- StateEvent.KnownStateEventTypes.FirstOrDefault(x =>
+ MatrixEvent.KnownEventTypes.FirstOrDefault(x =>
x.GetCustomAttributes<MatrixEventAttribute>()?
.Any(y => y.EventName == eventType) ?? false) ?? typeof(UnknownEventContent)
)!
@@ -89,7 +92,7 @@ public class CreateRoomRequest {
var request = new CreateRoomRequest {
Name = name ?? "New public Room",
Visibility = "public",
- CreationContent = new JsonObject(),
+ CreationContent = new(),
PowerLevelContentOverride = new RoomPowerLevelEventContent {
EventsDefault = 0,
UsersDefault = 0,
@@ -119,7 +122,7 @@ public class CreateRoomRequest {
}
},
RoomAliasName = roomAliasName,
- InitialState = new List<StateEvent>()
+ InitialState = new List<MatrixEvent>()
};
return request;
@@ -129,7 +132,7 @@ public class CreateRoomRequest {
var request = new CreateRoomRequest {
Name = name ?? "New private Room",
Visibility = "private",
- CreationContent = new JsonObject(),
+ CreationContent = new(),
PowerLevelContentOverride = new RoomPowerLevelEventContent {
EventsDefault = 0,
UsersDefault = 0,
@@ -159,7 +162,7 @@ public class CreateRoomRequest {
}
},
RoomAliasName = roomAliasName,
- InitialState = new List<StateEvent>()
+ InitialState = new List<MatrixEvent>()
};
return request;
diff --git a/LibMatrix/EventIdResponse.cs b/LibMatrix/Responses/EventIdResponse.cs
index 6a04229..9e23210 100644
--- a/LibMatrix/EventIdResponse.cs
+++ b/LibMatrix/Responses/EventIdResponse.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
-namespace LibMatrix;
+namespace LibMatrix.Responses;
public class EventIdResponse {
[JsonPropertyName("event_id")]
diff --git a/LibMatrix/Responses/Federation/ServerKeysResponse.cs b/LibMatrix/Responses/Federation/ServerKeysResponse.cs
new file mode 100644
index 0000000..cb62e34
--- /dev/null
+++ b/LibMatrix/Responses/Federation/ServerKeysResponse.cs
@@ -0,0 +1,55 @@
+using System.Diagnostics;
+using System.Text.Json.Serialization;
+using LibMatrix.Abstractions;
+
+namespace LibMatrix.Responses.Federation;
+
+public class ServerKeysResponse {
+ [JsonPropertyName("server_name")]
+ public string ServerName { get; set; }
+
+ [JsonPropertyName("valid_until_ts")]
+ public ulong ValidUntilTs { get; set; }
+
+ [JsonIgnore]
+ public DateTime ValidUntil {
+ get => DateTimeOffset.FromUnixTimeMilliseconds((long)ValidUntilTs).DateTime;
+ set => ValidUntilTs = (ulong)new DateTimeOffset(value).ToUnixTimeMilliseconds();
+ }
+
+ [JsonPropertyName("verify_keys")]
+ public Dictionary<string, CurrentVerifyKey> VerifyKeys { get; set; } = new();
+
+ [JsonIgnore]
+ public Dictionary<VersionedKeyId, CurrentVerifyKey> VerifyKeysById {
+ get => VerifyKeys.ToDictionary(key => (VersionedKeyId)key.Key, key => key.Value);
+ set => VerifyKeys = value.ToDictionary(key => (string)key.Key, key => key.Value);
+ }
+
+ [JsonPropertyName("old_verify_keys")]
+ public Dictionary<string, ExpiredVerifyKey> OldVerifyKeys { get; set; } = new();
+
+ [JsonIgnore]
+ public Dictionary<VersionedKeyId, ExpiredVerifyKey> OldVerifyKeysById {
+ get => OldVerifyKeys.ToDictionary(key => (VersionedKeyId)key.Key, key => key.Value);
+ set => OldVerifyKeys = value.ToDictionary(key => (string)key.Key, key => key.Value);
+ }
+
+ [DebuggerDisplay("{Key}")]
+ public class CurrentVerifyKey {
+ [JsonPropertyName("key")]
+ public string Key { get; set; }
+ }
+
+ [DebuggerDisplay("{Key} (expired {Expired})")]
+ public class ExpiredVerifyKey : CurrentVerifyKey {
+ [JsonPropertyName("expired_ts")]
+ public ulong ExpiredTs { get; set; }
+
+ [JsonIgnore]
+ public DateTime Expired {
+ get => DateTimeOffset.FromUnixTimeMilliseconds((long)ExpiredTs).DateTime;
+ set => ExpiredTs = (ulong)new DateTimeOffset(value).ToUnixTimeMilliseconds();
+ }
+ }
+}
diff --git a/LibMatrix/Responses/Federation/ServerVersionResponse.cs b/LibMatrix/Responses/Federation/ServerVersionResponse.cs
new file mode 100644
index 0000000..b09bdd0
--- /dev/null
+++ b/LibMatrix/Responses/Federation/ServerVersionResponse.cs
@@ -0,0 +1,16 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Responses.Federation;
+
+public class ServerVersionResponse {
+ [JsonPropertyName("server")]
+ public required ServerInfo Server { get; set; }
+
+ public class ServerInfo {
+ [JsonPropertyName("name")]
+ public string Name { get; set; }
+
+ [JsonPropertyName("version")]
+ public string Version { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Responses/Federation/SignedObject.cs b/LibMatrix/Responses/Federation/SignedObject.cs
new file mode 100644
index 0000000..3f6ffd6
--- /dev/null
+++ b/LibMatrix/Responses/Federation/SignedObject.cs
@@ -0,0 +1,68 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using ArcaneLibs.Extensions;
+using LibMatrix.Abstractions;
+using LibMatrix.Homeservers;
+
+namespace LibMatrix.Responses.Federation;
+
+[JsonConverter(typeof(SignedObjectConverterFactory))]
+public class SignedObject<T> {
+ [JsonPropertyName("signatures")]
+ public Dictionary<string, Dictionary<string, string>> Signatures { get; set; } = new();
+
+ [JsonIgnore]
+ public Dictionary<string, Dictionary<VersionedKeyId, string>> SignaturesById {
+ get => Signatures.ToDictionary(server => server.Key, server => server.Value.ToDictionary(key => (VersionedKeyId)key.Key, key => key.Value));
+ set => Signatures = value.ToDictionary(server => server.Key, server => server.Value.ToDictionary(key => (string)key.Key, key => key.Value));
+ }
+
+ [JsonExtensionData]
+ public required JsonObject Content { get; set; }
+
+ [JsonIgnore]
+ public T TypedContent {
+ get => Content.Deserialize<T>() ?? throw new JsonException("Failed to deserialize TypedContent from Content.");
+ set => Content = JsonSerializer.Deserialize<JsonObject>(JsonSerializer.Serialize(value)) ?? new JsonObject();
+ }
+}
+
+public class SignedObjectConverter<T> : JsonConverter<SignedObject<T>> {
+ public override SignedObject<T> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
+ var jsonObject = JsonSerializer.Deserialize<JsonObject>(ref reader, options);
+ if (jsonObject == null) {
+ throw new JsonException("Failed to deserialize SignedObject, JSON object is null.");
+ }
+
+ var signatures = jsonObject["signatures"] ?? throw new JsonException("Failed to find 'signatures' property in JSON object.");
+ jsonObject.Remove("signatures");
+
+ var signedObject = new SignedObject<T> {
+ Content = jsonObject,
+ Signatures = signatures.Deserialize<Dictionary<string, Dictionary<string, string>>>()
+ ?? throw new JsonException("Failed to deserialize 'signatures' property into Dictionary<string, Dictionary<string, string>>.")
+ };
+
+ return signedObject;
+ }
+
+ public override void Write(Utf8JsonWriter writer, SignedObject<T> value, JsonSerializerOptions options) {
+ var targetObj = value.Content.DeepClone();
+ targetObj["signatures"] = value.Signatures.ToJsonNode();
+ JsonSerializer.Serialize(writer, targetObj, options);
+ }
+}
+
+internal class SignedObjectConverterFactory : JsonConverterFactory {
+ public override bool CanConvert(Type typeToConvert) {
+ if (!typeToConvert.IsGenericType) return false;
+ return typeToConvert.GetGenericTypeDefinition() == typeof(SignedObject<>);
+ }
+
+ public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) {
+ var wrappedType = typeToConvert.GetGenericArguments()[0];
+ var converter = (JsonConverter)Activator.CreateInstance(typeof(SignedObjectConverter<>).MakeGenericType(wrappedType))!;
+ return converter;
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Responses/LoginResponse.cs b/LibMatrix/Responses/LoginResponse.cs
index 2f78932..1944276 100644
--- a/LibMatrix/Responses/LoginResponse.cs
+++ b/LibMatrix/Responses/LoginResponse.cs
@@ -19,11 +19,6 @@ public class LoginResponse {
[JsonPropertyName("user_id")]
public string UserId { get; set; }
-
- // public async Task<AuthenticatedHomeserverGeneric> GetAuthenticatedHomeserver(string? proxy = null) {
- // var urls = await new HomeserverResolverService().ResolveHomeserverFromWellKnown(Homeserver);
- // await AuthenticatedHomeserverGeneric.Create<AuthenticatedHomeserverGeneric>(Homeserver, AccessToken, proxy);
- // }
}
public class LoginRequest {
diff --git a/LibMatrix/MessagesResponse.cs b/LibMatrix/Responses/MessagesResponse.cs
index 526da74..1b412fe 100644
--- a/LibMatrix/MessagesResponse.cs
+++ b/LibMatrix/Responses/MessagesResponse.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
-namespace LibMatrix;
+namespace LibMatrix.Responses;
public class MessagesResponse {
[JsonPropertyName("start")]
@@ -10,8 +10,8 @@ public class MessagesResponse {
public string? End { get; set; }
[JsonPropertyName("chunk")]
- public List<StateEventResponse> Chunk { get; set; } = new();
+ public List<MatrixEventResponse> Chunk { get; set; } = new();
[JsonPropertyName("state")]
- public List<StateEventResponse> State { get; set; } = new();
+ public List<MatrixEventResponse> State { get; set; } = new();
}
\ No newline at end of file
diff --git a/LibMatrix/Responses/SyncResponse.cs b/LibMatrix/Responses/SyncResponse.cs
index d79e820..362ccc4 100644
--- a/LibMatrix/Responses/SyncResponse.cs
+++ b/LibMatrix/Responses/SyncResponse.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics;
using System.Text.Json.Serialization;
using LibMatrix.EventTypes.Spec.State.RoomInfo;
@@ -43,7 +44,7 @@ public class SyncResponse {
// supporting classes
public class PresenceDataStructure {
[JsonPropertyName("events")]
- public List<StateEventResponse>? Events { get; set; }
+ public List<MatrixEventResponse>? Events { get; set; }
}
public class RoomsDataStructure {
@@ -115,13 +116,13 @@ public class SyncResponse {
public class TimelineDataStructure : EventList {
public TimelineDataStructure() { }
- public TimelineDataStructure(List<StateEventResponse>? events, bool? limited) {
+ public TimelineDataStructure(List<MatrixEventResponse>? events, bool? limited) {
Events = events;
Limited = limited;
}
// [JsonPropertyName("events")]
- // public List<StateEventResponse>? Events { get; set; }
+ // public List<MatrixEventResponse>? Events { get; set; }
[JsonPropertyName("prev_batch")]
public string? PrevBatch { get; set; }
@@ -138,6 +139,7 @@ public class SyncResponse {
public int HighlightCount { get; set; }
}
+ [DebuggerDisplay("{JoinedMemberCount} joined, {InvitedMemberCount} invited, Heroes: {string.Join(\", \", Heroes ?? [])}")]
public class SummaryDataStructure {
[JsonPropertyName("m.heroes")]
public List<string>? Heroes { get; set; }
@@ -161,9 +163,9 @@ public class SyncResponse {
AccountData?.Events?.Max(x => x.OriginServerTs) ?? 0,
Presence?.Events?.Max(x => x.OriginServerTs) ?? 0,
ToDevice?.Events?.Max(x => x.OriginServerTs) ?? 0,
- Rooms?.Join?.Values?.Max(x => x.Timeline?.Events?.Max(y => y.OriginServerTs)) ?? 0,
- Rooms?.Invite?.Values?.Max(x => x.InviteState?.Events?.Max(y => y.OriginServerTs)) ?? 0,
- Rooms?.Leave?.Values?.Max(x => x.Timeline?.Events?.Max(y => y.OriginServerTs)) ?? 0
+ Rooms?.Join?.Values.Max(x => x.Timeline?.Events?.Max(y => y.OriginServerTs)) ?? 0,
+ Rooms?.Invite?.Values.Max(x => x.InviteState?.Events?.Max(y => y.OriginServerTs)) ?? 0,
+ Rooms?.Leave?.Values.Max(x => x.Timeline?.Events?.Max(y => y.OriginServerTs)) ?? 0
]).Max();
}
diff --git a/LibMatrix/UserIdAndReason.cs b/LibMatrix/Responses/UserIdAndReason.cs
index 99c9eaf..176cf7c 100644
--- a/LibMatrix/UserIdAndReason.cs
+++ b/LibMatrix/Responses/UserIdAndReason.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
-namespace LibMatrix;
+namespace LibMatrix.Responses;
internal class UserIdAndReason(string userId = null!, string reason = null!) {
[JsonPropertyName("user_id")]
diff --git a/LibMatrix/WhoAmIResponse.cs b/LibMatrix/Responses/WhoAmIResponse.cs
index 10fff35..db47152 100644
--- a/LibMatrix/WhoAmIResponse.cs
+++ b/LibMatrix/Responses/WhoAmIResponse.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
-namespace LibMatrix;
+namespace LibMatrix.Responses;
public class WhoAmIResponse {
[JsonPropertyName("user_id")]
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;
diff --git a/LibMatrix/Services/HomeserverProviderService.cs b/LibMatrix/Services/HomeserverProviderService.cs
index 36bc828..52aadd2 100644
--- a/LibMatrix/Services/HomeserverProviderService.cs
+++ b/LibMatrix/Services/HomeserverProviderService.cs
@@ -2,6 +2,7 @@ using System.Net.Http.Json;
using ArcaneLibs.Collections;
using LibMatrix.Homeservers;
using LibMatrix.Responses;
+using LibMatrix.Responses.Federation;
using Microsoft.Extensions.Logging;
namespace LibMatrix.Services;
@@ -16,7 +17,7 @@ public class HomeserverProviderService(ILogger<HomeserverProviderService> logger
if (!enableClient && !enableServer)
throw new ArgumentException("At least one of enableClient or enableServer must be true");
- return await AuthenticatedHomeserverCache.GetOrAdd($"{homeserver}{accessToken}{proxy}{impersonatedMxid}", async () => {
+ return await AuthenticatedHomeserverCache.GetOrAdd($"{homeserver}{accessToken}{proxy}{impersonatedMxid}{useGeneric}{enableClient}{enableServer}", async () => {
var wellKnownUris = await hsResolver.ResolveHomeserverFromWellKnown(homeserver, enableClient, enableServer);
var rhs = new RemoteHomeserver(homeserver, wellKnownUris, proxy);
diff --git a/LibMatrix/Services/HomeserverResolverService.cs b/LibMatrix/Services/HomeserverResolverService.cs
index 53cd2dd..ed1d2e3 100644
--- a/LibMatrix/Services/HomeserverResolverService.cs
+++ b/LibMatrix/Services/HomeserverResolverService.cs
@@ -9,7 +9,9 @@ using Microsoft.Extensions.Logging.Abstractions;
namespace LibMatrix.Services;
public class HomeserverResolverService {
- private readonly MatrixHttpClient _httpClient = new();
+ private readonly MatrixHttpClient _httpClient = new() {
+ RetryOnNetworkError = false
+ };
private static readonly SemaphoreCache<WellKnownUris> WellKnownCache = new();
@@ -44,7 +46,17 @@ public class HomeserverResolverService {
return res;
});
}
-
+
+ private async Task<T?> GetFromJsonAsync<T>(string url) {
+ try {
+ return await _httpClient.GetFromJsonAsync<T>(url);
+ }
+ catch (Exception e) {
+ _logger.LogWarning(e, "Failed to get JSON from {url}", url);
+ return default;
+ }
+ }
+
private async Task<string?> _tryResolveClientEndpoint(string homeserver) {
ArgumentNullException.ThrowIfNull(homeserver);
_logger.LogTrace("Resolving client well-known: {homeserver}", homeserver);
@@ -52,14 +64,20 @@ public class HomeserverResolverService {
homeserver = homeserver.TrimEnd('/');
// check if homeserver has a client well-known
if (homeserver.StartsWith("https://")) {
- clientWellKnown = await _httpClient.TryGetFromJsonAsync<ClientWellKnown>($"{homeserver}/.well-known/matrix/client");
+ clientWellKnown = await GetFromJsonAsync<ClientWellKnown>($"{homeserver}/.well-known/matrix/client");
+
+ if (clientWellKnown is null && await MatrixHttpClient.CheckSuccessStatus($"{homeserver}/_matrix/client/versions"))
+ return homeserver;
}
else if (homeserver.StartsWith("http://")) {
- clientWellKnown = await _httpClient.TryGetFromJsonAsync<ClientWellKnown>($"{homeserver}/.well-known/matrix/client");
+ clientWellKnown = await GetFromJsonAsync<ClientWellKnown>($"{homeserver}/.well-known/matrix/client");
+
+ if (clientWellKnown is null && await MatrixHttpClient.CheckSuccessStatus($"{homeserver}/_matrix/client/versions"))
+ return homeserver;
}
else {
- clientWellKnown ??= await _httpClient.TryGetFromJsonAsync<ClientWellKnown>($"https://{homeserver}/.well-known/matrix/client");
- clientWellKnown ??= await _httpClient.TryGetFromJsonAsync<ClientWellKnown>($"http://{homeserver}/.well-known/matrix/client");
+ clientWellKnown ??= await GetFromJsonAsync<ClientWellKnown>($"https://{homeserver}/.well-known/matrix/client");
+ clientWellKnown ??= await GetFromJsonAsync<ClientWellKnown>($"http://{homeserver}/.well-known/matrix/client");
if (clientWellKnown is null) {
if (await MatrixHttpClient.CheckSuccessStatus($"https://{homeserver}/_matrix/client/versions"))
@@ -84,14 +102,14 @@ public class HomeserverResolverService {
homeserver = homeserver.TrimEnd('/');
// check if homeserver has a server well-known
if (homeserver.StartsWith("https://")) {
- serverWellKnown = await _httpClient.TryGetFromJsonAsync<ServerWellKnown>($"{homeserver}/.well-known/matrix/server");
+ serverWellKnown = await GetFromJsonAsync<ServerWellKnown>($"{homeserver}/.well-known/matrix/server");
}
else if (homeserver.StartsWith("http://")) {
- serverWellKnown = await _httpClient.TryGetFromJsonAsync<ServerWellKnown>($"{homeserver}/.well-known/matrix/server");
+ serverWellKnown = await GetFromJsonAsync<ServerWellKnown>($"{homeserver}/.well-known/matrix/server");
}
else {
- serverWellKnown ??= await _httpClient.TryGetFromJsonAsync<ServerWellKnown>($"https://{homeserver}/.well-known/matrix/server");
- serverWellKnown ??= await _httpClient.TryGetFromJsonAsync<ServerWellKnown>($"http://{homeserver}/.well-known/matrix/server");
+ serverWellKnown ??= await GetFromJsonAsync<ServerWellKnown>($"https://{homeserver}/.well-known/matrix/server");
+ serverWellKnown ??= await GetFromJsonAsync<ServerWellKnown>($"http://{homeserver}/.well-known/matrix/server");
}
_logger.LogInformation("Server well-known for {hs}: {json}", homeserver, serverWellKnown?.ToJson() ?? "null");
@@ -115,15 +133,6 @@ public class HomeserverResolverService {
_logger.LogInformation("No server well-known for {server}...", homeserver);
return null;
}
-
- [Obsolete("Use authenticated media, available on AuthenticatedHomeserverGeneric", true)]
- public async Task<string?> ResolveMediaUri(string homeserver, string mxc) {
- if (homeserver is null) throw new ArgumentNullException(nameof(homeserver));
- if (mxc is null) throw new ArgumentNullException(nameof(mxc));
- if (!mxc.StartsWith("mxc://")) throw new InvalidDataException("mxc must start with mxc://");
- homeserver = (await ResolveHomeserverFromWellKnown(homeserver)).Client;
- return mxc.Replace("mxc://", $"{homeserver}/_matrix/media/v3/download/");
- }
public class WellKnownUris {
public string? Client { get; set; }
diff --git a/LibMatrix/Services/WellKnownResolver/WellKnownResolverService.cs b/LibMatrix/Services/WellKnownResolver/WellKnownResolverService.cs
index 4c78347..c5e9d9c 100644
--- a/LibMatrix/Services/WellKnownResolver/WellKnownResolverService.cs
+++ b/LibMatrix/Services/WellKnownResolver/WellKnownResolverService.cs
@@ -34,17 +34,13 @@ public class WellKnownResolverService {
WellKnownResolverConfiguration? config = null) {
WellKnownRecords records = new();
_logger.LogDebug($"Resolving well-knowns for {homeserver}");
- if (includeClient && await _clientWellKnownResolver.TryResolveWellKnown(homeserver, config ?? _configuration) is { } clientResult) {
- records.ClientWellKnown = clientResult;
- }
-
- if (includeServer && await _serverWellKnownResolver.TryResolveWellKnown(homeserver, config ?? _configuration) is { } serverResult) {
- records.ServerWellKnown = serverResult;
- }
-
- if (includeSupport && await _supportWellKnownResolver.TryResolveWellKnown(homeserver, config ?? _configuration) is { } supportResult) {
- records.SupportWellKnown = supportResult;
- }
+ var clientTask = _clientWellKnownResolver.TryResolveWellKnown(homeserver, config ?? _configuration);
+ var serverTask = _serverWellKnownResolver.TryResolveWellKnown(homeserver, config ?? _configuration);
+ var supportTask = _supportWellKnownResolver.TryResolveWellKnown(homeserver, config ?? _configuration);
+
+ if (includeClient && await clientTask is { } clientResult) records.ClientWellKnown = clientResult;
+ if (includeServer && await serverTask is { } serverResult) records.ServerWellKnown = serverResult;
+ if (includeSupport && await supportTask is { } supportResult) records.SupportWellKnown = supportResult;
return records;
}
@@ -75,8 +71,10 @@ public class WellKnownResolverService {
public struct WellKnownResolutionWarning {
public WellKnownResolutionWarningType Type { get; set; }
public string Message { get; set; }
+
[JsonIgnore]
public Exception? Exception { get; set; }
+
public string? ExceptionMessage => Exception?.Message;
[JsonConverter(typeof(JsonStringEnumConverter))]
diff --git a/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/ClientWellKnownResolver.cs b/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/ClientWellKnownResolver.cs
index f8de38d..f52b217 100644
--- a/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/ClientWellKnownResolver.cs
+++ b/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/ClientWellKnownResolver.cs
@@ -14,8 +14,6 @@ public class ClientWellKnownResolver(ILogger<ClientWellKnownResolver> logger, We
StoreNulls = false
};
- private static readonly MatrixHttpClient HttpClient = new();
-
public Task<WellKnownResolverService.WellKnownResolutionResult<ClientWellKnown>> TryResolveWellKnown(string homeserver, WellKnownResolverConfiguration? config = null) {
config ??= configuration;
return ClientWellKnownCache.TryGetOrAdd(homeserver, async () => {
diff --git a/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/ServerWellKnownResolver.cs b/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/ServerWellKnownResolver.cs
index a99185c..a48d846 100644
--- a/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/ServerWellKnownResolver.cs
+++ b/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/ServerWellKnownResolver.cs
@@ -1,6 +1,5 @@
using System.Text.Json.Serialization;
using ArcaneLibs.Collections;
-using LibMatrix.Extensions;
using Microsoft.Extensions.Logging;
using WellKnownType = LibMatrix.Services.WellKnownResolver.WellKnownResolvers.ServerWellKnown;
using ResultType =
@@ -14,8 +13,6 @@ public class ServerWellKnownResolver(ILogger<ServerWellKnownResolver> logger, We
StoreNulls = false
};
- private static readonly MatrixHttpClient HttpClient = new();
-
public Task<WellKnownResolverService.WellKnownResolutionResult<ServerWellKnown>> TryResolveWellKnown(string homeserver, WellKnownResolverConfiguration? config = null) {
config ??= configuration;
return ClientWellKnownCache.TryGetOrAdd(homeserver, async () => {
@@ -25,13 +22,11 @@ public class ServerWellKnownResolver(ILogger<ServerWellKnownResolver> logger, We
await TryGetWellKnownFromUrl($"https://{homeserver}/.well-known/matrix/server", WellKnownResolverService.WellKnownSource.Https);
if (result.Content != null) return result;
-
return result;
});
}
}
-
public class ServerWellKnown {
[JsonPropertyName("m.server")]
public string Homeserver { get; set; }
diff --git a/LibMatrix/StateEvent.cs b/LibMatrix/StateEvent.cs
index e2ac87e..861b584 100644
--- a/LibMatrix/StateEvent.cs
+++ b/LibMatrix/StateEvent.cs
@@ -1,4 +1,5 @@
using System.Collections.Frozen;
+using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Text.Json;
@@ -12,10 +13,10 @@ using LibMatrix.Extensions;
namespace LibMatrix;
-public class StateEvent {
- public static FrozenSet<Type> KnownStateEventTypes { get; } = ClassCollector<EventContent>.ResolveFromAllAccessibleAssemblies().ToFrozenSet();
+public class MatrixEvent {
+ public static FrozenSet<Type> KnownEventTypes { get; } = ClassCollector<EventContent>.ResolveFromAllAccessibleAssemblies().ToFrozenSet();
- public static FrozenDictionary<string, Type> KnownStateEventTypesByName { get; } = KnownStateEventTypes.Aggregate(
+ public static FrozenDictionary<string, Type> KnownEventTypesByName { get; } = KnownEventTypes.Aggregate(
new Dictionary<string, Type>(),
(dict, type) => {
var attrs = type.GetCustomAttributes<MatrixEventAttribute>();
@@ -28,11 +29,11 @@ public class StateEvent {
return dict;
}).OrderBy(x => x.Key).ToFrozenDictionary();
- public static Type GetStateEventType(string? type) =>
- string.IsNullOrWhiteSpace(type) ? typeof(UnknownEventContent) : KnownStateEventTypesByName.GetValueOrDefault(type) ?? typeof(UnknownEventContent);
+ public static Type GetEventType(string? type) =>
+ string.IsNullOrWhiteSpace(type) ? typeof(UnknownEventContent) : KnownEventTypesByName.GetValueOrDefault(type) ?? typeof(UnknownEventContent);
[JsonIgnore]
- public Type MappedType => GetStateEventType(Type);
+ public Type MappedType => GetEventType(Type);
[JsonIgnore]
public bool IsLegacyType => MappedType.GetCustomAttributes<MatrixEventAttribute>().FirstOrDefault(x => x.EventName == Type)?.Legacy ?? false;
@@ -57,7 +58,7 @@ public class StateEvent {
public EventContent? TypedContent {
get {
try {
- var mappedType = GetStateEventType(Type);
+ var mappedType = GetEventType(Type);
if (mappedType == typeof(UnknownEventContent))
Console.WriteLine($"Warning: unknown event type '{Type}'");
var deserialisedContent = (EventContent)RawContent.Deserialize(mappedType, TypedContentSerializerOptions)!;
@@ -120,14 +121,29 @@ public class StateEvent {
[JsonIgnore]
public string InternalContentTypeName => TypedContent?.GetType().Name ?? "null";
- public static bool TypeKeyPairMatches(StateEventResponse x, StateEventResponse y) => x.Type == y.Type && x.StateKey == y.StateKey;
- public static bool Equals(StateEventResponse x, StateEventResponse y) => x.Type == y.Type && x.StateKey == y.StateKey && x.RawContent.Equals(y.RawContent);
+ public static bool TypeKeyPairMatches(MatrixEventResponse x, MatrixEventResponse y) => x.Type == y.Type && x.StateKey == y.StateKey;
+ public static bool Equals(MatrixEventResponse x, MatrixEventResponse y) => x.Type == y.Type && x.StateKey == y.StateKey && x.EventId == y.EventId;
+
+ /// <summary>
+ /// Compares two state events for deep equality, including type, state key, and raw content.
+ /// If you trust the server, use Equals instead, as that compares by event ID instead of raw content.
+ /// </summary>
+ /// <param name="x"></param>
+ /// <param name="y"></param>
+ /// <returns></returns>
+ public static bool DeepEquals(MatrixEventResponse x, MatrixEventResponse y) => x.Type == y.Type && x.StateKey == y.StateKey && JsonNode.DeepEquals(x.RawContent, y.RawContent);
}
-public class StateEventResponse : StateEvent {
+public class MatrixEventResponse : MatrixEvent {
[JsonPropertyName("origin_server_ts")]
public long? OriginServerTs { get; set; }
+ [JsonIgnore]
+ public DateTime? OriginServerTimestamp {
+ get => OriginServerTs.HasValue ? DateTimeOffset.FromUnixTimeMilliseconds(OriginServerTs.Value).UtcDateTime : DateTime.MinValue;
+ set => OriginServerTs = value is null ? null : new DateTimeOffset(value.Value).ToUnixTimeMilliseconds();
+ }
+
[JsonPropertyName("room_id")]
public string? RoomId { get; set; }
@@ -162,26 +178,27 @@ public class StateEventResponse : StateEvent {
}
[JsonSourceGenerationOptions(WriteIndented = true)]
-[JsonSerializable(typeof(ChunkedStateEventResponse))]
-internal partial class ChunkedStateEventResponseSerializerContext : JsonSerializerContext;
+[JsonSerializable(typeof(ChunkedMatrixEventResponse))]
+internal partial class ChunkedMatrixEventResponseSerializerContext : JsonSerializerContext;
+[DebuggerDisplay("{Events.Count} events")]
public class EventList {
public EventList() { }
- public EventList(List<StateEventResponse>? events) {
+ public EventList(List<MatrixEventResponse>? events) {
Events = events;
}
[JsonPropertyName("events")]
- public List<StateEventResponse>? Events { get; set; } = new();
+ public List<MatrixEventResponse>? Events { get; set; } = new();
}
-public class ChunkedStateEventResponse {
+public class ChunkedMatrixEventResponse {
[JsonPropertyName("chunk")]
- public List<StateEventResponse>? Chunk { get; set; } = new();
+ public List<MatrixEventResponse>? Chunk { get; set; } = new();
}
-public class PaginatedChunkedStateEventResponse : ChunkedStateEventResponse {
+public class PaginatedChunkedMatrixEventResponse : ChunkedMatrixEventResponse {
[JsonPropertyName("start")]
public string? Start { get; set; }
@@ -189,7 +206,7 @@ public class PaginatedChunkedStateEventResponse : ChunkedStateEventResponse {
public string? End { get; set; }
}
-public class BatchedChunkedStateEventResponse : ChunkedStateEventResponse {
+public class BatchedChunkedMatrixEventResponse : ChunkedMatrixEventResponse {
[JsonPropertyName("next_batch")]
public string? NextBatch { get; set; }
@@ -197,7 +214,7 @@ public class BatchedChunkedStateEventResponse : ChunkedStateEventResponse {
public string? PrevBatch { get; set; }
}
-public class RecursedBatchedChunkedStateEventResponse : BatchedChunkedStateEventResponse {
+public class RecursedBatchedChunkedMatrixEventResponse : BatchedChunkedMatrixEventResponse {
[JsonPropertyName("recursion_depth")]
public int? RecursionDepth { get; set; }
}
@@ -218,7 +235,7 @@ public class StateEventContentPolymorphicTypeInfoResolver : DefaultJsonTypeInfoR
IgnoreUnrecognizedTypeDiscriminators = true,
UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType,
- DerivedTypes = StateEvent.KnownStateEventTypesByName.Select(x => new JsonDerivedType(x.Value, x.Key)).ToList()
+ DerivedTypes = MatrixEvent.KnownEventTypesByName.Select(x => new JsonDerivedType(x.Value, x.Key)).ToList()
// DerivedTypes = new ClassCollector<EventContent>()
// .ResolveFromAllAccessibleAssemblies()
diff --git a/LibMatrix/MxcUri.cs b/LibMatrix/StructuredData/MxcUri.cs
index 875ae53..82a9677 100644
--- a/LibMatrix/MxcUri.cs
+++ b/LibMatrix/StructuredData/MxcUri.cs
@@ -1,6 +1,6 @@
using System.Diagnostics.CodeAnalysis;
-namespace LibMatrix;
+namespace LibMatrix.StructuredData;
public class MxcUri {
public required string ServerName { get; set; }
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
diff --git a/LibMatrix/deps.json b/LibMatrix/deps.json
new file mode 100644
index 0000000..d6ba81b
--- /dev/null
+++ b/LibMatrix/deps.json
@@ -0,0 +1,12 @@
+[
+ {
+ "pname": "Microsoft.Extensions.DependencyInjection.Abstractions",
+ "version": "10.0.0-rc.2.25502.107",
+ "hash": "sha256-1nh8z2nglCizQkl0iWwJ/au4BAuuBu0xghKHGBeTM1I="
+ },
+ {
+ "pname": "Microsoft.Extensions.Logging.Abstractions",
+ "version": "10.0.0-rc.2.25502.107",
+ "hash": "sha256-krml7WL+lF7oiYOvQ8NHQp7BVpHJrLIHhyxUgkHO+WE="
+ }
+]
|