diff --git a/ArcaneLibs b/ArcaneLibs
-Subproject 209108f67cddce1b7b94eabcd8ab38805bcfe3e
+Subproject 2956a7ce4e8d12034322a91b6afa449e7035485
diff --git a/LibMatrix.EventTypes/LibMatrix.EventTypes.csproj b/LibMatrix.EventTypes/LibMatrix.EventTypes.csproj
index ae2f4de..647b68f 100644
--- a/LibMatrix.EventTypes/LibMatrix.EventTypes.csproj
+++ b/LibMatrix.EventTypes/LibMatrix.EventTypes.csproj
@@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
- <PackageReference Include="ArcaneLibs" Version="1.0.0-preview.20250208-191806" Condition="'$(Configuration)' == 'Release'" />
+ <PackageReference Include="ArcaneLibs" Version="1.0.0-preview.20250307-202359" Condition="'$(Configuration)' == 'Release'" />
<ProjectReference Include="..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj" Condition="'$(Configuration)' == 'Debug'"/>
</ItemGroup>
diff --git a/LibMatrix/Extensions/MatrixHttpClient.Single.cs b/LibMatrix/Extensions/MatrixHttpClient.Single.cs
index b42d94e..42f81a8 100644
--- a/LibMatrix/Extensions/MatrixHttpClient.Single.cs
+++ b/LibMatrix/Extensions/MatrixHttpClient.Single.cs
@@ -10,6 +10,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using ArcaneLibs;
using ArcaneLibs.Extensions;
+using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Requests;
namespace LibMatrix.Extensions;
@@ -91,7 +92,14 @@ public class MatrixHttpClient {
responseMessage = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
}
catch (Exception e) {
- if (!e.Message.StartsWith("TypeError: NetworkError"))
+ if (e is TaskCanceledException or TimeoutException) {
+ if (request.Method == HttpMethod.Get && !cancellationToken.IsCancellationRequested) {
+ await Task.Delay(Random.Shared.Next(500, 2500), cancellationToken);
+ request.ResetSendStatus();
+ return await SendAsync(request, cancellationToken);
+ }
+ }
+ 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;
@@ -238,5 +246,17 @@ public class MatrixHttpClient {
};
return await SendAsync(request, cancellationToken);
}
+
+ public async Task DeleteAsync(string url) {
+ var request = new HttpRequestMessage(HttpMethod.Delete, url);
+ await SendAsync(request);
+ }
+
+ public async Task<HttpResponseMessage> DeleteAsJsonAsync<T>(string url, T payload) {
+ var request = new HttpRequestMessage(HttpMethod.Delete, url) {
+ Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")
+ };
+ return await SendAsync(request);
+ }
}
#endif
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/Extensions/NamedCaches/NamedCache.cs b/LibMatrix/Homeservers/Extensions/NamedCaches/NamedCache.cs
index 622eef6..9f11fa0 100644
--- a/LibMatrix/Homeservers/Extensions/NamedCaches/NamedCache.cs
+++ b/LibMatrix/Homeservers/Extensions/NamedCaches/NamedCache.cs
@@ -3,35 +3,65 @@ namespace LibMatrix.Homeservers.Extensions.NamedCaches;
public class NamedCache<T>(AuthenticatedHomeserverGeneric hs, string name) where T : class {
private Dictionary<string, T>? _cache = new();
private DateTime _expiry = DateTime.MinValue;
-
+ private SemaphoreSlim _lock = new(1, 1);
+
+ public TimeSpan ExpiryTime { get; set; } = TimeSpan.FromMinutes(5);
+ public DateTime GetCurrentExpiryTime() => _expiry;
+
+ /// <summary>
+ /// Update the cached map with the latest data from the homeserver.
+ /// </summary>
+ /// <returns>The updated data</returns>
public async Task<Dictionary<string, T>> ReadCacheMapAsync() {
- _cache = await hs.GetAccountDataOrNullAsync<Dictionary<string, T>>(name);
+ _cache = await hs.GetAccountDataAsync<Dictionary<string, T>>(name);
return _cache ?? new();
}
-
- public async Task<Dictionary<string,T>> ReadCacheMapCachedAsync() {
+
+ public async Task<Dictionary<string, T>> ReadCacheMapCachedAsync() {
+ await _lock.WaitAsync();
if (_expiry < DateTime.Now || _cache == null) {
_cache = await ReadCacheMapAsync();
- _expiry = DateTime.Now.AddMinutes(5);
+ _expiry = DateTime.Now.Add(ExpiryTime);
}
+ _lock.Release();
+
return _cache;
}
-
- public virtual async Task<T?> GetValueAsync(string key) {
- return (await ReadCacheMapCachedAsync()).GetValueOrDefault(key);
+
+ public virtual async Task<T?> GetValueAsync(string key, bool useCache = true) {
+ return (await (useCache ? ReadCacheMapCachedAsync() : ReadCacheMapAsync())).GetValueOrDefault(key);
}
-
- public virtual async Task<T> SetValueAsync(string key, T value) {
- var cache = await ReadCacheMapCachedAsync();
+
+ public virtual async Task<T> SetValueAsync(string key, T value, bool unsafeUseCache = false) {
+ if (!unsafeUseCache)
+ await _lock.WaitAsync();
+ var cache = await (unsafeUseCache ? ReadCacheMapCachedAsync() : ReadCacheMapAsync());
cache[key] = value;
await hs.SetAccountDataAsync(name, cache);
+ if (!unsafeUseCache)
+ _lock.Release();
+
return value;
}
-
- public virtual async Task<T> GetOrSetValueAsync(string key, Func<Task<T>> value) {
- return (await ReadCacheMapCachedAsync()).GetValueOrDefault(key) ?? await SetValueAsync(key, await value());
+
+ public virtual async Task<T> RemoveValueAsync(string key, bool unsafeUseCache = false) {
+ if (!unsafeUseCache)
+ await _lock.WaitAsync();
+ var cache = await (unsafeUseCache ? ReadCacheMapCachedAsync() : ReadCacheMapAsync());
+ var removedValue = cache[key];
+ cache.Remove(key);
+ await hs.SetAccountDataAsync(name, cache);
+
+ if (!unsafeUseCache)
+ _lock.Release();
+
+ return removedValue;
+ }
+
+ public virtual async Task<T> GetOrSetValueAsync(string key, Func<Task<T>> value, bool unsafeUseCache = false) {
+ return (await (unsafeUseCache ? ReadCacheMapCachedAsync() : ReadCacheMapAsync())).GetValueOrDefault(key) ?? await SetValueAsync(key, await value());
}
}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalEventReportQueryFilter.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalEventReportQueryFilter.cs
new file mode 100644
index 0000000..c34ad7c
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalEventReportQueryFilter.cs
@@ -0,0 +1,27 @@
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Filters;
+
+public class SynapseAdminLocalEventReportQueryFilter {
+ 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/Filters/SynapseAdminLocalRoomQueryFilter.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalRoomQueryFilter.cs
new file mode 100644
index 0000000..b8929a0
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalRoomQueryFilter.cs
@@ -0,0 +1,27 @@
+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; }
+}
\ 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
new file mode 100644
index 0000000..62b291b
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Filters/SynapseAdminLocalUserQueryFilter.cs
@@ -0,0 +1,27 @@
+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/Requests/AdminRoomDeleteRequest.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/AdminRoomDeleteRequest.cs
deleted file mode 100644
index f4c927a..0000000
--- a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/AdminRoomDeleteRequest.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-using System.Text.Json.Serialization;
-
-namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Requests;
-
-public class AdminRoomDeleteRequest {
- [JsonPropertyName("new_room_user_id")]
- public string? NewRoomUserId { get; set; }
-
- [JsonPropertyName("room_name")]
- public string? RoomName { get; set; }
-
- [JsonPropertyName("block")]
- public bool Block { get; set; }
-
- [JsonPropertyName("purge")]
- public bool Purge { get; set; }
-
- [JsonPropertyName("message")]
- public string? Message { get; set; }
-
- [JsonPropertyName("force_purge")]
- public bool ForcePurge { get; set; }
-}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/SynapseAdminRegistrationTokenCreateRequest.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/SynapseAdminRegistrationTokenCreateRequest.cs
new file mode 100644
index 0000000..197fd5d
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/SynapseAdminRegistrationTokenCreateRequest.cs
@@ -0,0 +1,31 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseAdminRegistrationTokenUpdateRequest {
+ [JsonPropertyName("uses_allowed")]
+ public int? UsesAllowed { get; set; }
+
+ [JsonPropertyName("expiry_time")]
+ public long? ExpiryTime { get; set; }
+
+ [JsonIgnore]
+ public DateTime? ExpiresAt {
+ get => ExpiryTime.HasValue ? DateTimeOffset.FromUnixTimeMilliseconds(ExpiryTime.Value).DateTime : null;
+ set => ExpiryTime = value.HasValue ? new DateTimeOffset(value.Value).ToUnixTimeMilliseconds() : null;
+ }
+
+ [JsonIgnore]
+ public TimeSpan? ExpiresAfter {
+ get => ExpiryTime.HasValue ? DateTimeOffset.FromUnixTimeMilliseconds(ExpiryTime.Value).DateTime - DateTimeOffset.Now : null;
+ set => ExpiryTime = value.HasValue ? (DateTimeOffset.Now + value.Value).ToUnixTimeMilliseconds() : null;
+ }
+}
+
+public class SynapseAdminRegistrationTokenCreateRequest : SynapseAdminRegistrationTokenUpdateRequest {
+ [JsonPropertyName("token")]
+ public string? Token { get; set; }
+
+ [JsonPropertyName("length")]
+ public int? Length { get; set; }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/SynapseAdminRoomDeleteRequest.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/SynapseAdminRoomDeleteRequest.cs
new file mode 100644
index 0000000..aee2a7e
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Requests/SynapseAdminRoomDeleteRequest.cs
@@ -0,0 +1,54 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Requests;
+
+public class SynapseAdminRoomDeleteRequest {
+ [JsonPropertyName("new_room_user_id")]
+ public string? NewRoomUserId { get; set; }
+
+ [JsonPropertyName("room_name")]
+ public string? RoomName { get; set; }
+
+ [JsonPropertyName("block")]
+ public bool Block { get; set; }
+
+ [JsonPropertyName("purge")]
+ public bool Purge { get; set; }
+
+ [JsonPropertyName("message")]
+ public string? Message { get; set; }
+
+ [JsonPropertyName("force_purge")]
+ public bool ForcePurge { get; set; }
+}
+
+public class SynapseAdminRoomDeleteResponse {
+ [JsonPropertyName("delete_id")]
+ public string DeleteId { get; set; } = null!;
+}
+
+public class SynapseAdminRoomDeleteStatusList {
+ [JsonPropertyName("results")]
+ public List<SynapseAdminRoomDeleteStatus> Results { get; set; }
+}
+public class SynapseAdminRoomDeleteStatus {
+ [JsonPropertyName("status")]
+ public string Status { get; set; } = null!;
+
+ [JsonPropertyName("shutdown_room")]
+ public RoomShutdownInfo ShutdownRoom { get; set; }
+
+ public class RoomShutdownInfo {
+ [JsonPropertyName("kicked_users")]
+ public List<string>? KickedUsers { get; set; }
+
+ [JsonPropertyName("failed_to_kick_users")]
+ public List<string>? FailedToKickUsers { get; set; }
+
+ [JsonPropertyName("local_aliases")]
+ public List<string>? LocalAliasses { get; set; }
+
+ [JsonPropertyName("new_room_id")]
+ public string? NewRoomId { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/BackgroundUpdates.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/BackgroundUpdates.cs
new file mode 100644
index 0000000..2394b98
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/BackgroundUpdates.cs
@@ -0,0 +1,28 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseAdminBackgroundUpdateStatusResponse {
+ [JsonPropertyName("enabled")]
+ public bool Enabled { get; set; }
+
+ [JsonPropertyName("current_updates")]
+ public Dictionary<string, BackgroundUpdateInfo> CurrentUpdates { get; set; }
+
+ public class BackgroundUpdateInfo {
+ [JsonPropertyName("name")]
+ public string Name { get; set; }
+
+ [JsonPropertyName("total_item_count")]
+ public int TotalItemCount { get; set; }
+
+ [JsonPropertyName("total_duration_ms")]
+ public double TotalDurationMs { get; set; }
+
+ [JsonPropertyName("average_items_per_ms")]
+ public double AverageItemsPerMs { get; set; }
+
+ [JsonIgnore]
+ public TimeSpan TotalDuration => TimeSpan.FromMilliseconds(TotalDurationMs);
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/Destinations.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/Destinations.cs
new file mode 100644
index 0000000..646a4b5
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/Destinations.cs
@@ -0,0 +1,56 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseAdminDestinationListResult : SynapseNextTokenTotalCollectionResult {
+ [JsonPropertyName("destinations")]
+ public List<SynapseAdminDestinationListResultDestination> Destinations { get; set; } = new();
+
+ public class SynapseAdminDestinationListResultDestination {
+ [JsonPropertyName("destination")]
+ public string Destination { get; set; }
+
+ [JsonPropertyName("retry_last_ts")]
+ public long RetryLastTs { get; set; }
+
+ [JsonPropertyName("retry_interval")]
+ public long RetryInterval { get; set; }
+
+ [JsonPropertyName("failure_ts")]
+ public long? FailureTs { get; set; }
+
+ [JsonPropertyName("last_successful_stream_ordering")]
+ public long? LastSuccessfulStreamOrdering { get; set; }
+
+ [JsonIgnore]
+ public DateTime? FailureTsDateTime {
+ get => FailureTs.HasValue ? DateTimeOffset.FromUnixTimeMilliseconds(FailureTs.Value).DateTime : null;
+ set => FailureTs = value.HasValue ? new DateTimeOffset(value.Value).ToUnixTimeMilliseconds() : null;
+ }
+
+ [JsonIgnore]
+ public DateTime? RetryLastTsDateTime {
+ get => DateTimeOffset.FromUnixTimeMilliseconds(RetryLastTs).DateTime;
+ set => RetryLastTs = new DateTimeOffset(value.Value).ToUnixTimeMilliseconds();
+ }
+
+ [JsonIgnore]
+ public TimeSpan RetryIntervalTimeSpan {
+ get => TimeSpan.FromMilliseconds(RetryInterval);
+ set => RetryInterval = (long)value.TotalMilliseconds;
+ }
+ }
+}
+
+public class SynapseAdminDestinationRoomListResult : SynapseNextTokenTotalCollectionResult {
+ [JsonPropertyName("rooms")]
+ public List<SynapseAdminDestinationRoomListResultRoom> Rooms { get; set; } = new();
+
+ public class SynapseAdminDestinationRoomListResultRoom {
+ [JsonPropertyName("room_id")]
+ public string RoomId { get; set; }
+
+ [JsonPropertyName("stream_ordering")]
+ public int StreamOrdering { 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
new file mode 100644
index 0000000..10fc039
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/EventReportListResult.cs
@@ -0,0 +1,169 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using ArcaneLibs;
+using ArcaneLibs.Attributes;
+using ArcaneLibs.Extensions;
+using LibMatrix.EventTypes;
+using LibMatrix.Extensions;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseAdminEventReportListResult : SynapseNextTokenTotalCollectionResult {
+ [JsonPropertyName("event_reports")]
+ public List<SynapseAdminEventReportListResultReport> Reports { get; set; } = new();
+
+ public class SynapseAdminEventReportListResultReport {
+ [JsonPropertyName("event_id")]
+ public string EventId { get; set; }
+
+ [JsonPropertyName("id")]
+ public string Id { get; set; }
+
+ [JsonPropertyName("reason")]
+ public string? Reason { get; set; }
+
+ [JsonPropertyName("score")]
+ public int? Score { get; set; }
+
+ [JsonPropertyName("received_ts")]
+ public long ReceivedTs { get; set; }
+
+ [JsonPropertyName("canonical_alias")]
+ public string? CanonicalAlias { get; set; }
+
+ [JsonPropertyName("room_id")]
+ public string RoomId { get; set; }
+
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("sender")]
+ public string Sender { get; set; }
+
+ [JsonPropertyName("user_id")]
+ public string UserId { get; set; }
+
+ [JsonIgnore]
+ public DateTime ReceivedTsDateTime {
+ get => DateTimeOffset.FromUnixTimeMilliseconds(ReceivedTs).DateTime;
+ set => ReceivedTs = new DateTimeOffset(value).ToUnixTimeMilliseconds();
+ }
+ }
+
+ public class SynapseAdminEventReportListResultReportWithDetails : SynapseAdminEventReportListResultReport {
+ [JsonPropertyName("event_json")]
+ public SynapseEventJson EventJson { get; set; }
+
+ public class SynapseEventJson {
+ [JsonPropertyName("auth_events")]
+ public List<string> AuthEvents { get; set; }
+
+ [JsonPropertyName("content")]
+ public JsonObject? RawContent { get; set; }
+
+ [JsonPropertyName("depth")]
+ public int Depth { get; set; }
+
+ [JsonPropertyName("hashes")]
+ public Dictionary<string, string> Hashes { get; set; }
+
+ [JsonPropertyName("origin")]
+ public string Origin { get; set; }
+
+ [JsonPropertyName("origin_server_ts")]
+ public long OriginServerTs { get; set; }
+
+ [JsonPropertyName("prev_events")]
+ public List<string> PrevEvents { get; set; }
+
+ [JsonPropertyName("prev_state")]
+ public List<object> PrevState { get; set; }
+
+ [JsonPropertyName("room_id")]
+ public string RoomId { get; set; }
+
+ [JsonPropertyName("sender")]
+ public string Sender { get; set; }
+
+ [JsonPropertyName("signatures")]
+ public Dictionary<string, Dictionary<string, string>> Signatures { get; set; }
+
+ [JsonPropertyName("type")]
+ public string Type { get; set; }
+
+ [JsonPropertyName("unsigned")]
+ public JsonObject? Unsigned { get; set; }
+
+ // Extra... copied from StateEventResponse
+
+ [JsonIgnore]
+ public Type MappedType => StateEvent.GetStateEventType(Type);
+
+ [JsonIgnore]
+ public bool IsLegacyType => MappedType.GetCustomAttributes<MatrixEventAttribute>().FirstOrDefault(x => x.EventName == Type)?.Legacy ?? false;
+
+ [JsonIgnore]
+ public string FriendlyTypeName => MappedType.GetFriendlyNameOrNull() ?? Type;
+
+ [JsonIgnore]
+ public string FriendlyTypeNamePlural => MappedType.GetFriendlyNamePluralOrNull() ?? Type;
+
+ private static readonly JsonSerializerOptions TypedContentSerializerOptions = new() {
+ Converters = {
+ new JsonFloatStringConverter(),
+ new JsonDoubleStringConverter(),
+ new JsonDecimalStringConverter()
+ }
+ };
+
+ [JsonIgnore]
+ [SuppressMessage("ReSharper", "PropertyCanBeMadeInitOnly.Global")]
+ public EventContent? TypedContent {
+ get {
+ ClassCollector<EventContent>.ResolveFromAllAccessibleAssemblies();
+ // if (Type == "m.receipt") {
+ // return null;
+ // }
+ try {
+ var mappedType = StateEvent.GetStateEventType(Type);
+ if (mappedType == typeof(UnknownEventContent))
+ Console.WriteLine($"Warning: unknown event type '{Type}'");
+ var deserialisedContent = (EventContent)RawContent.Deserialize(mappedType, TypedContentSerializerOptions)!;
+ return deserialisedContent;
+ }
+ catch (JsonException e) {
+ Console.WriteLine(e);
+ Console.WriteLine("Content:\n" + (RawContent?.ToJson() ?? "null"));
+ }
+
+ return null;
+ }
+ set {
+ if (value is null)
+ RawContent?.Clear();
+ else
+ RawContent = JsonSerializer.Deserialize<JsonObject>(JsonSerializer.Serialize(value, value.GetType(),
+ new JsonSerializerOptions() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }));
+ }
+ }
+
+ //debug
+ [JsonIgnore]
+ public string InternalSelfTypeName {
+ get {
+ var res = GetType().Name switch {
+ "StateEvent`1" => "StateEvent",
+ _ => GetType().Name
+ };
+ return res;
+ }
+ }
+
+ [JsonIgnore]
+ public string InternalContentTypeName => TypedContent?.GetType().Name ?? "null";
+ }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RegistrationTokenListResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RegistrationTokenListResult.cs
new file mode 100644
index 0000000..fa92ef9
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RegistrationTokenListResult.cs
@@ -0,0 +1,31 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseAdminRegistrationTokenListResult {
+ [JsonPropertyName("registration_tokens")]
+ public List<SynapseAdminRegistrationTokenListResultToken> RegistrationTokens { get; set; } = new();
+
+ public class SynapseAdminRegistrationTokenListResultToken {
+ [JsonPropertyName("token")]
+ public string Token { get; set; }
+
+ [JsonPropertyName("uses_allowed")]
+ public int? UsesAllowed { get; set; }
+
+ [JsonPropertyName("pending")]
+ public int Pending { get; set; }
+
+ [JsonPropertyName("completed")]
+ public int Completed { get; set; }
+
+ [JsonPropertyName("expiry_time")]
+ public long? ExpiryTime { get; set; }
+
+ [JsonIgnore]
+ public DateTime? ExpiryTimeDateTime {
+ get => ExpiryTime.HasValue ? DateTimeOffset.FromUnixTimeMilliseconds(ExpiryTime.Value).DateTime : null;
+ set => ExpiryTime = value.HasValue ? new DateTimeOffset(value.Value).ToUnixTimeMilliseconds() : null;
+ }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/AdminRoomListingResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RoomListResult.cs
index 7ab96ac..d84c89b 100644
--- a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/AdminRoomListingResult.cs
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RoomListResult.cs
@@ -1,8 +1,8 @@
using System.Text.Json.Serialization;
-namespace LibMatrix.Responses.Admin;
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
-public class AdminRoomListingResult {
+public class SynapseAdminRoomListResult {
[JsonPropertyName("offset")]
public int Offset { get; set; }
@@ -16,9 +16,9 @@ public class AdminRoomListingResult {
public int? PrevBatch { get; set; }
[JsonPropertyName("rooms")]
- public List<AdminRoomListingResultRoom> Rooms { get; set; } = new();
+ public List<SynapseAdminRoomListResultRoom> Rooms { get; set; } = new();
- public class AdminRoomListingResultRoom {
+ public class SynapseAdminRoomListResultRoom {
[JsonPropertyName("room_id")]
public required string RoomId { get; set; }
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RoomMediaListResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RoomMediaListResult.cs
new file mode 100644
index 0000000..97e85ad
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/RoomMediaListResult.cs
@@ -0,0 +1,11 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseAdminRoomMediaListResult {
+ [JsonPropertyName("local")]
+ public List<string> Local { get; set; } = new();
+
+ [JsonPropertyName("remote")]
+ public List<string> Remote { get; set; } = new();
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminRoomMemberListResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminRoomMemberListResult.cs
new file mode 100644
index 0000000..cb2ec08
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminRoomMemberListResult.cs
@@ -0,0 +1,11 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseAdminRoomMemberListResult {
+ [JsonPropertyName("members")]
+ public List<string> Members { get; set; }
+
+ [JsonPropertyName("total")]
+ public int Total { get; set; }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminUserRedactIdResponse.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminUserRedactIdResponse.cs
new file mode 100644
index 0000000..3f5f865
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseAdminUserRedactIdResponse.cs
@@ -0,0 +1,22 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseAdminUserRedactIdResponse {
+ [JsonPropertyName("redact_id")]
+ public string RedactionId { get; set; }
+}
+
+public class SynapseAdminRedactStatusResponse {
+ /// <summary>
+ /// One of "scheduled", "active", "completed", "failed"
+ /// </summary>
+ [JsonPropertyName("status")]
+ public string Status { get; set; }
+
+ /// <summary>
+ /// Key: Event ID, Value: Error message
+ /// </summary>
+ [JsonPropertyName("failed_redactions")]
+ public Dictionary<string, string> FailedRedactions { get; set; }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseCollectionResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseCollectionResult.cs
new file mode 100644
index 0000000..36a5596
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseCollectionResult.cs
@@ -0,0 +1,250 @@
+using System.Buffers;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using ArcaneLibs.Extensions;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseNextTokenTotalCollectionResult {
+ [JsonPropertyName("total")]
+ public int Total { get; set; }
+
+ [JsonPropertyName("next_token")]
+ public string? NextToken { get; set; }
+}
+
+// [JsonConverter(typeof(SynapseCollectionJsonConverter<>))]
+public class SynapseCollectionResult<T>(string chunkKey = "chunk", string prevTokenKey = "prev_token", string nextTokenKey = "next_token", string totalKey = "total") {
+ public int? Total { get; set; }
+ public string? PrevToken { get; set; }
+ public string? NextToken { get; set; }
+ public List<T> Chunk { get; set; } = [];
+
+ // TODO: figure out how to provide an IAsyncEnumerable<T> for this
+ // https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/use-utf8jsonreader#read-from-a-stream-using-utf8jsonreader
+
+ // public async IAsyncEnumerable<T> FromJsonAsync(Stream stream) {
+ //
+ // }
+
+ public SynapseCollectionResult<T> FromJson(Stream stream, Action<T> action) {
+ byte[] buffer = new byte[4096];
+ _ = stream.Read(buffer);
+ var reader = new Utf8JsonReader(buffer, isFinalBlock: false, state: default);
+
+ try {
+ FromJsonInternal(stream, ref buffer, ref reader, action);
+ }
+ catch (JsonException e) {
+ Console.WriteLine($"Caught a JsonException: {e}");
+ int hexdumpWidth = 64;
+ Console.WriteLine($"Check hexdump line {reader.BytesConsumed / hexdumpWidth} index {reader.BytesConsumed % hexdumpWidth}");
+ buffer.HexDump(64);
+ }
+ finally { }
+
+ return this;
+ }
+
+ private void FromJsonInternal(Stream stream, ref byte[] buffer, ref Utf8JsonReader reader, Action<T> action) {
+ while (!reader.IsFinalBlock) {
+ while (!reader.Read()) {
+ GetMoreBytesFromStream(stream, ref buffer, ref reader);
+ }
+
+ if (reader.TokenType == JsonTokenType.PropertyName) {
+ var propName = reader.GetString();
+ Console.WriteLine($"SynapseCollectionResult: encountered property name: {propName}");
+
+ while (!reader.Read()) {
+ GetMoreBytesFromStream(stream, ref buffer, ref reader);
+ }
+
+ Console.WriteLine($"{reader.BytesConsumed}/{stream.Position} {reader.TokenType}");
+
+ if (propName == totalKey && reader.TokenType == JsonTokenType.Number) {
+ Total = reader.GetInt32();
+ }
+ else if (propName == prevTokenKey && reader.TokenType == JsonTokenType.String) {
+ PrevToken = reader.GetString();
+ }
+ else if (propName == nextTokenKey && reader.TokenType == JsonTokenType.String) {
+ NextToken = reader.GetString();
+ }
+ else if (propName == chunkKey) {
+ if (reader.TokenType == JsonTokenType.StartArray) {
+ while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) {
+ // if (reader.TokenType == JsonTokenType.EndArray) {
+ // break;
+ // }
+ // Console.WriteLine($"Encountered token in chunk: {reader.TokenType}");
+ // var _buf = reader.ValueSequence.ToArray();
+ // try {
+ // var item = JsonSerializer.Deserialize<T>(_buf);
+ // action(item);
+ // Chunk.Add(item);
+ // }
+ // catch(JsonException e) {
+ // Console.WriteLine($"Caught a JsonException: {e}");
+ // int hexdumpWidth = 64;
+ //
+ // // Console.WriteLine($"Check hexdump line {reader.BytesConsumed / hexdumpWidth} index {reader.BytesConsumed % hexdumpWidth}");
+ // Console.WriteLine($"Buffer length: {_buf.Length}");
+ // _buf.HexDump(64);
+ // throw;
+ // }
+ var item = ReadItem(stream, ref buffer, ref reader);
+ action(item);
+ Chunk.Add(item);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private T ReadItem(Stream stream, ref byte[] buffer, ref Utf8JsonReader reader) {
+ while (!reader.Read()) {
+ GetMoreBytesFromStream(stream, ref buffer, ref reader);
+ }
+
+ // handle nullable types
+ if (typeof(T).IsGenericType && typeof(T).GetGenericTypeDefinition() == typeof(Nullable<>)) {
+ if (reader.TokenType == JsonTokenType.Null) {
+ return default(T);
+ }
+ }
+
+ // if(typeof(T) == typeof(string)) {
+ // return (T)(object)reader.GetString();
+ // }
+ // else if(typeof(T) == typeof(int)) {
+ // return (T)(object)reader.GetInt32();
+ // }
+ // else {
+ // var _buf = reader.ValueSequence.ToArray();
+ // return JsonSerializer.Deserialize<T>(_buf);
+ // }
+
+ // default branch uses "object?" cast to avoid compiler error
+ // add more branches here as nessesary
+ // reader.Read();
+ var call = typeof(T) switch {
+ Type t when t == typeof(string) => reader.GetString(),
+ _ => ReadObject<T>(stream, ref buffer, ref reader)
+ };
+
+ object ReadObject<T>(Stream stream, ref byte[] buffer, ref Utf8JsonReader reader) {
+ if (reader.TokenType != JsonTokenType.PropertyName) {
+ throw new JsonException();
+ }
+
+ List<byte> objBuffer = [(byte)'{', ..reader.ValueSequence.ToArray()];
+ var currentDepth = reader.CurrentDepth;
+ while (reader.CurrentDepth >= currentDepth) {
+ while (!reader.Read()) {
+ GetMoreBytesFromStream(stream, ref buffer, ref reader);
+ }
+
+ if (reader.TokenType == JsonTokenType.EndObject && reader.CurrentDepth == currentDepth) {
+ break;
+ }
+
+ objBuffer.AddRange(reader.ValueSpan);
+ }
+
+ return JsonSerializer.Deserialize<T>(objBuffer.ToArray());
+ }
+
+ return (T)call;
+
+ // return JsonSerializer.Deserialize<T>(ref reader);
+ }
+
+ private static void GetMoreBytesFromStream(Stream stream, ref byte[] buffer, ref Utf8JsonReader reader) {
+ int bytesRead;
+ if (reader.BytesConsumed < buffer.Length) {
+ ReadOnlySpan<byte> leftover = buffer.AsSpan((int)reader.BytesConsumed);
+
+ if (leftover.Length == buffer.Length) {
+ Array.Resize(ref buffer, buffer.Length * 2);
+ Console.WriteLine($"Increased buffer size to {buffer.Length}");
+ }
+
+ leftover.CopyTo(buffer);
+ bytesRead = stream.Read(buffer.AsSpan(leftover.Length));
+ }
+ else {
+ bytesRead = stream.Read(buffer);
+ }
+
+ // Console.WriteLine($"String in buffer is: {Encoding.UTF8.GetString(buffer)}");
+ reader = new Utf8JsonReader(buffer, isFinalBlock: bytesRead == 0, reader.CurrentState);
+ }
+}
+
+public partial class SynapseCollectionJsonConverter<T> : JsonConverter<SynapseCollectionResult<T>> {
+ public override SynapseCollectionResult<T>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
+ if (reader.TokenType != JsonTokenType.StartObject) {
+ throw new JsonException();
+ }
+
+ var result = new SynapseCollectionResult<T>();
+ while (reader.Read()) {
+ if (reader.TokenType == JsonTokenType.EndObject) {
+ break;
+ }
+
+ if (reader.TokenType != JsonTokenType.PropertyName) {
+ throw new JsonException();
+ }
+
+ var propName = reader.GetString();
+ reader.Read();
+ if (propName == "total") {
+ result.Total = reader.GetInt32();
+ }
+ else if (propName == "prev_token") {
+ result.PrevToken = reader.GetString();
+ }
+ else if (propName == "next_token") {
+ result.NextToken = reader.GetString();
+ }
+ else if (propName == "chunk") {
+ if (reader.TokenType != JsonTokenType.StartArray) {
+ throw new JsonException();
+ }
+
+ while (reader.Read()) {
+ if (reader.TokenType == JsonTokenType.EndArray) {
+ break;
+ }
+
+ var item = JsonSerializer.Deserialize<T>(ref reader, options);
+ result.Chunk.Add(item);
+ }
+ }
+ }
+
+ return result;
+ }
+
+ public override void Write(Utf8JsonWriter writer, SynapseCollectionResult<T> value, JsonSerializerOptions options) {
+ writer.WriteStartObject();
+ if (value.Total is not null)
+ writer.WriteNumber("total", value.Total ?? 0);
+ if (value.PrevToken is not null)
+ writer.WriteString("prev_token", value.PrevToken);
+ if (value.NextToken is not null)
+ writer.WriteString("next_token", value.NextToken);
+
+ writer.WriteStartArray("chunk");
+ foreach (var item in value.Chunk) {
+ JsonSerializer.Serialize(writer, item, options);
+ }
+
+ writer.WriteEndArray();
+ writer.WriteEndObject();
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseUserMediaResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseUserMediaResult.cs
new file mode 100644
index 0000000..5530cc3
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/SynapseUserMediaResult.cs
@@ -0,0 +1,40 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseAdminUserMediaResult {
+ [JsonPropertyName("total")]
+ public int Total { get; set; }
+
+ [JsonPropertyName("next_token")]
+ public string? NextToken { get; set; }
+
+ [JsonPropertyName("media")]
+ public List<MediaInfo> Media { get; set; } = new();
+
+ public class MediaInfo {
+ [JsonPropertyName("created_ts")]
+ public long CreatedTimestamp { get; set; }
+
+ [JsonPropertyName("last_access_ts")]
+ public long? LastAccessTimestamp { get; set; }
+
+ [JsonPropertyName("media_id")]
+ public string MediaId { get; set; }
+
+ [JsonPropertyName("media_length")]
+ public int MediaLength { get; set; }
+
+ [JsonPropertyName("media_type")]
+ public string MediaType { get; set; }
+
+ [JsonPropertyName("quarantined_by")]
+ public string? QuarantinedBy { get; set; }
+
+ [JsonPropertyName("safe_from_quarantine")]
+ public bool SafeFromQuarantine { get; set; }
+
+ [JsonPropertyName("upload_name")]
+ public string UploadName { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/UserListResult.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/UserListResult.cs
new file mode 100644
index 0000000..3132906
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/Models/Responses/UserListResult.cs
@@ -0,0 +1,71 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+
+public class SynapseAdminUserListResult {
+ [JsonPropertyName("offset")]
+ public int Offset { get; set; }
+
+ [JsonPropertyName("total")]
+ public int Total { get; set; }
+
+ [JsonPropertyName("next_token")]
+ public string? NextToken { get; set; }
+
+ [JsonPropertyName("users")]
+ public List<SynapseAdminUserListResultUser> Users { get; set; } = new();
+
+ public class SynapseAdminUserListResultUser {
+ [JsonPropertyName("name")]
+ public string Name { get; set; }
+
+ [JsonPropertyName("is_guest")]
+ public bool? IsGuest { get; set; }
+
+ [JsonPropertyName("admin")]
+ public bool? Admin { get; set; }
+
+ [JsonPropertyName("user_type")]
+ public string? UserType { get; set; }
+
+ [JsonPropertyName("deactivated")]
+ public bool Deactivated { get; set; }
+
+ [JsonPropertyName("erased")]
+ public bool Erased { get; set; }
+
+ [JsonPropertyName("shadow_banned")]
+ public bool ShadowBanned { get; set; }
+
+ [JsonPropertyName("displayname")]
+ public string? DisplayName { get; set; }
+
+ [JsonPropertyName("avatar_url")]
+ public string? AvatarUrl { get; set; }
+
+ [JsonPropertyName("creation_ts")]
+ public long CreationTs { get; set; }
+
+ [JsonPropertyName("last_seen_ts")]
+ public long? LastSeenTs { get; set; }
+
+ [JsonPropertyName("locked")]
+ public bool Locked { get; set; }
+
+ // Requires enabling MSC3866
+ [JsonPropertyName("approved")]
+ public bool? Approved { get; set; }
+
+ [JsonIgnore]
+ public DateTime CreationTsDateTime {
+ get => DateTimeOffset.FromUnixTimeMilliseconds(CreationTs).DateTime;
+ set => CreationTs = new DateTimeOffset(value).ToUnixTimeMilliseconds();
+ }
+
+ [JsonIgnore]
+ public DateTime? LastSeenTsDateTime {
+ get => LastSeenTs.HasValue ? DateTimeOffset.FromUnixTimeMilliseconds(LastSeenTs.Value).DateTime : null;
+ set => LastSeenTs = value.HasValue ? new DateTimeOffset(value.Value).ToUnixTimeMilliseconds() : null;
+ }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminApiClient.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminApiClient.cs
index ac94a7a..3ed7311 100644
--- a/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminApiClient.cs
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminApiClient.cs
@@ -1,90 +1,155 @@
+// #define LOG_SKIP
+
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Text.Json.Nodes;
using ArcaneLibs.Extensions;
-using LibMatrix.Filters;
-using LibMatrix.Responses.Admin;
+using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Filters;
+using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Requests;
+using LibMatrix.Homeservers.ImplementationDetails.Synapse.Models.Responses;
+using LibMatrix.Responses;
namespace LibMatrix.Homeservers.ImplementationDetails.Synapse;
public class SynapseAdminApiClient(AuthenticatedHomeserverSynapse authenticatedHomeserver) {
- public async IAsyncEnumerable<AdminRoomListingResult.AdminRoomListingResultRoom> SearchRoomsAsync(int limit = int.MaxValue, string orderBy = "name", string dir = "f",
- string? searchTerm = null, LocalRoomQueryFilter? localFilter = null) {
- AdminRoomListingResult? res = null;
+ private SynapseAdminUserCleanupExecutor UserCleanupExecutor { get; } = new(authenticatedHomeserver);
+ // https://github.com/element-hq/synapse/tree/develop/docs/admin_api
+ // https://github.com/element-hq/synapse/tree/develop/docs/usage/administration/admin_api
+
+#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) {
+ SynapseAdminRoomListResult? res = null;
var i = 0;
int? totalRooms = null;
do {
- var url = $"/_synapse/admin/v1/rooms?limit={Math.Min(limit, 250)}&dir={dir}&order_by={orderBy}";
+ 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 (res?.NextBatch is not null) url += $"&from={res.NextBatch}";
Console.WriteLine($"--- ADMIN Querying Room List with URL: {url} - Already have {i} items... ---");
- res = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<AdminRoomListingResult>(url);
+ res = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRoomListResult>(url);
totalRooms ??= res.TotalRooms;
- Console.WriteLine(res.ToJson(false));
+ // Console.WriteLine(res.ToJson(false));
foreach (var room in res.Rooms) {
if (localFilter is not null) {
- if (!room.RoomId.Contains(localFilter.RoomIdContains)) {
+ if (!string.IsNullOrWhiteSpace(localFilter.RoomIdContains) && !room.RoomId.Contains(localFilter.RoomIdContains, StringComparison.OrdinalIgnoreCase)) {
totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule roomid.");
+#endif
continue;
}
- if (!room.Name?.Contains(localFilter.NameContains) == true) {
+ if (!string.IsNullOrWhiteSpace(localFilter.NameContains) && room.Name?.Contains(localFilter.NameContains, StringComparison.OrdinalIgnoreCase) != true) {
totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule roomname.");
+#endif
continue;
}
- if (!room.CanonicalAlias?.Contains(localFilter.CanonicalAliasContains) == true) {
+ if (!string.IsNullOrWhiteSpace(localFilter.CanonicalAliasContains) &&
+ room.CanonicalAlias?.Contains(localFilter.CanonicalAliasContains, StringComparison.OrdinalIgnoreCase) != true) {
totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule alias.");
+#endif
continue;
}
- if (!room.Version.Contains(localFilter.VersionContains)) {
+ if (!string.IsNullOrWhiteSpace(localFilter.VersionContains) && !room.Version.Contains(localFilter.VersionContains, StringComparison.OrdinalIgnoreCase)) {
totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule version.");
+#endif
continue;
}
- if (!room.Creator.Contains(localFilter.CreatorContains)) {
+ if (!string.IsNullOrWhiteSpace(localFilter.CreatorContains) && !room.Creator.Contains(localFilter.CreatorContains, StringComparison.OrdinalIgnoreCase)) {
totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule creator.");
+#endif
continue;
}
- if (!room.Encryption?.Contains(localFilter.EncryptionContains) == true) {
+ if (!string.IsNullOrWhiteSpace(localFilter.EncryptionContains) &&
+ room.Encryption?.Contains(localFilter.EncryptionContains, StringComparison.OrdinalIgnoreCase) != true) {
totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule encryption.");
+#endif
continue;
}
- if (!room.JoinRules?.Contains(localFilter.JoinRulesContains) == true) {
+ if (!string.IsNullOrWhiteSpace(localFilter.JoinRulesContains) &&
+ room.JoinRules?.Contains(localFilter.JoinRulesContains, StringComparison.OrdinalIgnoreCase) != true) {
totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule joinrules.");
+#endif
continue;
}
- if (!room.GuestAccess?.Contains(localFilter.GuestAccessContains) == true) {
+ if (!string.IsNullOrWhiteSpace(localFilter.GuestAccessContains) &&
+ room.GuestAccess?.Contains(localFilter.GuestAccessContains, StringComparison.OrdinalIgnoreCase) != true) {
totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule guestaccess.");
+#endif
continue;
}
- if (!room.HistoryVisibility?.Contains(localFilter.HistoryVisibilityContains) == true) {
+ if (!string.IsNullOrWhiteSpace(localFilter.HistoryVisibilityContains) &&
+ room.HistoryVisibility?.Contains(localFilter.HistoryVisibilityContains, StringComparison.OrdinalIgnoreCase) != true) {
totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule history visibility.");
+#endif
continue;
}
if (localFilter.CheckFederation && room.Federatable != localFilter.Federatable) {
totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule federation.");
+#endif
continue;
}
if (localFilter.CheckPublic && room.Public != localFilter.Public) {
totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule public.");
+#endif
continue;
}
+ if (room.StateEvents < localFilter.StateEventsGreaterThan || room.StateEvents > localFilter.StateEventsLessThan) {
+ totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule joined local members.");
+#endif
+ continue;
+ }
+
if (room.JoinedMembers < localFilter.JoinedMembersGreaterThan || room.JoinedMembers > localFilter.JoinedMembersLessThan) {
totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule joined members: {localFilter.JoinedMembersGreaterThan} < {room.JoinedLocalMembers} < {localFilter.JoinedMembersLessThan}.");
+#endif
continue;
}
if (room.JoinedLocalMembers < localFilter.JoinedLocalMembersGreaterThan || room.JoinedLocalMembers > localFilter.JoinedLocalMembersLessThan) {
totalRooms--;
+#if LOG_SKIP
+ Console.WriteLine($"Skipped room {room.ToJson(indent: false)} on rule joined local members: {localFilter.JoinedLocalMembersGreaterThan} < {room.JoinedLocalMembers} < {localFilter.JoinedLocalMembersLessThan}.");
+#endif
continue;
}
}
@@ -104,4 +169,367 @@ public class SynapseAdminApiClient(AuthenticatedHomeserverSynapse authenticatedH
}
} while (i < Math.Min(limit, totalRooms ?? limit));
}
+
+#endregion
+
+#region Users
+
+ public async IAsyncEnumerable<SynapseAdminUserListResult.SynapseAdminUserListResultUser> SearchUsersAsync(int limit = int.MaxValue, int chunkLimit = 250,
+ SynapseAdminLocalUserQueryFilter? localFilter = null) {
+ // TODO: implement filters
+ string? from = null;
+ while (limit > 0) {
+ 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);
+ 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());
+ foreach (var user in res.Users) {
+ limit--;
+ yield return user;
+ }
+
+ if (string.IsNullOrWhiteSpace(res.NextToken)) break;
+ from = res.NextToken;
+ }
+ }
+
+ public async Task<LoginResponse> LoginUserAsync(string userId, TimeSpan expireAfter) {
+ var url = new Uri($"/_synapse/admin/v1/users/{userId.UrlEncode()}/login", UriKind.Relative);
+ url.AddQuery("valid_until_ms", DateTimeOffset.UtcNow.Add(expireAfter).ToUnixTimeMilliseconds().ToString());
+ var resp = await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync<JsonObject>(url.ToString(), new());
+ var loginResp = await resp.Content.ReadFromJsonAsync<LoginResponse>();
+ loginResp.UserId = userId; // Synapse only returns the access token
+ return loginResp;
+ }
+
+#endregion
+
+#region Reports
+
+ public async IAsyncEnumerable<SynapseAdminEventReportListResult.SynapseAdminEventReportListResultReport> GetEventReportsAsync(int limit = int.MaxValue, int chunkLimit = 250,
+ string dir = "f", SynapseAdminLocalEventReportQueryFilter? filter = null) {
+ // TODO: implement filters
+ string? from = null;
+ while (limit > 0) {
+ var url = new Uri("/_synapse/admin/v1/event_reports", UriKind.Relative);
+ url = url.AddQuery("limit", Math.Min(limit, chunkLimit).ToString());
+ if (!string.IsNullOrWhiteSpace(from)) url = url.AddQuery("from", from);
+ Console.WriteLine($"--- ADMIN Querying Reports with URL: {url} ---");
+ var res = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminEventReportListResult>(url.ToString());
+ foreach (var report in res.Reports) {
+ limit--;
+ yield return report;
+ }
+
+ if (string.IsNullOrWhiteSpace(res.NextToken)) break;
+ from = res.NextToken;
+ }
+ }
+
+ public async Task<SynapseAdminEventReportListResult.SynapseAdminEventReportListResultReportWithDetails> GetEventReportDetailsAsync(string reportId) {
+ var url = new Uri($"/_synapse/admin/v1/event_reports/{reportId.UrlEncode()}", UriKind.Relative);
+ return await authenticatedHomeserver.ClientHttpClient
+ .GetFromJsonAsync<SynapseAdminEventReportListResult.SynapseAdminEventReportListResultReportWithDetails>(url.ToString());
+ }
+
+ // Utility function to get details straight away
+ public async IAsyncEnumerable<SynapseAdminEventReportListResult.SynapseAdminEventReportListResultReportWithDetails> GetEventReportsWithDetailsAsync(int limit = int.MaxValue,
+ int chunkLimit = 250, string dir = "f", SynapseAdminLocalEventReportQueryFilter? filter = null) {
+ Queue<Task<SynapseAdminEventReportListResult.SynapseAdminEventReportListResultReportWithDetails>> tasks = [];
+ await foreach (var report in GetEventReportsAsync(limit, chunkLimit, dir, filter)) {
+ tasks.Enqueue(GetEventReportDetailsAsync(report.Id));
+ while (tasks.Peek().IsCompleted) yield return await tasks.Dequeue(); // early return if possible
+ }
+
+ while (tasks.Count > 0) yield return await tasks.Dequeue();
+ }
+
+ public async Task DeleteEventReportAsync(string reportId) {
+ var url = new Uri($"/_synapse/admin/v1/event_reports/{reportId.UrlEncode()}", UriKind.Relative);
+ await authenticatedHomeserver.ClientHttpClient.DeleteAsync(url.ToString());
+ }
+
+#endregion
+
+#region Background Updates
+
+ public async Task<bool> GetBackgroundUpdatesEnabledAsync() {
+ var url = new Uri("/_synapse/admin/v1/background_updates/enabled", UriKind.Relative);
+ // The return type is technically wrong, but includes the field we want.
+ var resp = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminBackgroundUpdateStatusResponse>(url.ToString());
+ return resp.Enabled;
+ }
+
+ public async Task<bool> SetBackgroundUpdatesEnabledAsync(bool enabled) {
+ var url = new Uri("/_synapse/admin/v1/background_updates/enabled", UriKind.Relative);
+ // The used types are technically wrong, but include the field we want.
+ var resp = await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync<JsonObject>(url.ToString(), new JsonObject {
+ ["enabled"] = enabled
+ });
+ var json = await resp.Content.ReadFromJsonAsync<SynapseAdminBackgroundUpdateStatusResponse>();
+ return json!.Enabled;
+ }
+
+ public async Task<SynapseAdminBackgroundUpdateStatusResponse> GetBackgroundUpdatesStatusAsync() {
+ var url = new Uri("/_synapse/admin/v1/background_updates/status", UriKind.Relative);
+ return await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminBackgroundUpdateStatusResponse>(url.ToString());
+ }
+
+ /// <summary>
+ /// Run a background job
+ /// </summary>
+ /// <param name="jobName">One of "populate_stats_process_rooms" or "regenerate_directory"</param>
+ public async Task RunBackgroundJobsAsync(string jobName) {
+ var url = new Uri("/_synapse/admin/v1/background_updates/run", UriKind.Relative);
+ await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync(url.ToString(), new JsonObject() {
+ ["job_name"] = jobName
+ });
+ }
+
+#endregion
+
+#region Federation
+
+ public async IAsyncEnumerable<SynapseAdminDestinationListResult.SynapseAdminDestinationListResultDestination> GetFederationDestinationsAsync(int limit = int.MaxValue,
+ int chunkLimit = 250) {
+ string? from = null;
+ while (limit > 0) {
+ var url = new Uri("/_synapse/admin/v1/federation/destinations", UriKind.Relative);
+ url = url.AddQuery("limit", Math.Min(limit, chunkLimit).ToString());
+ if (!string.IsNullOrWhiteSpace(from)) url = url.AddQuery("from", from);
+ Console.WriteLine($"--- ADMIN Querying Federation Destinations with URL: {url} ---");
+ var res = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminDestinationListResult>(url.ToString());
+ foreach (var dest in res.Destinations) {
+ limit--;
+ yield return dest;
+ }
+ }
+ }
+
+ public async Task<SynapseAdminDestinationListResult.SynapseAdminDestinationListResultDestination> GetFederationDestinationDetailsAsync(string destination) {
+ var url = new Uri($"/_synapse/admin/v1/federation/destinations/{destination}", UriKind.Relative);
+ return await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminDestinationListResult.SynapseAdminDestinationListResultDestination>(url.ToString());
+ }
+
+ public async IAsyncEnumerable<SynapseAdminDestinationRoomListResult.SynapseAdminDestinationRoomListResultRoom> GetFederationDestinationRoomsAsync(string destination,
+ int limit = int.MaxValue, int chunkLimit = 250) {
+ string? from = null;
+ while (limit > 0) {
+ var url = new Uri($"/_synapse/admin/v1/federation/destinations/{destination}/rooms", UriKind.Relative);
+ url = url.AddQuery("limit", Math.Min(limit, chunkLimit).ToString());
+ if (!string.IsNullOrWhiteSpace(from)) url = url.AddQuery("from", from);
+ Console.WriteLine($"--- ADMIN Querying Federation Destination Rooms with URL: {url} ---");
+ var res = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminDestinationRoomListResult>(url.ToString());
+ foreach (var room in res.Rooms) {
+ limit--;
+ yield return room;
+ }
+ }
+ }
+
+ public async Task ResetFederationConnectionTimeoutAsync(string destination) {
+ await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync($"/_synapse/admin/v1/federation/destinations/{destination}/reset_connection", new JsonObject());
+ }
+
+#endregion
+
+#region Registration Tokens
+
+ // does not support pagination
+ public async Task<List<SynapseAdminRegistrationTokenListResult.SynapseAdminRegistrationTokenListResultToken>> GetRegistrationTokensAsync() {
+ var url = new Uri("/_synapse/admin/v1/registration_tokens", UriKind.Relative);
+ var resp = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRegistrationTokenListResult>(url.ToString());
+ return resp.RegistrationTokens;
+ }
+
+ public async Task<SynapseAdminRegistrationTokenListResult.SynapseAdminRegistrationTokenListResultToken> GetRegistrationTokenAsync(string token) {
+ var url = new Uri($"/_synapse/admin/v1/registration_tokens/{token.UrlEncode()}", UriKind.Relative);
+ var resp =
+ await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRegistrationTokenListResult.SynapseAdminRegistrationTokenListResultToken>(url.ToString());
+ return resp;
+ }
+
+ public async Task<SynapseAdminRegistrationTokenListResult.SynapseAdminRegistrationTokenListResultToken> CreateRegistrationTokenAsync(
+ SynapseAdminRegistrationTokenCreateRequest request) {
+ var url = new Uri("/_synapse/admin/v1/", UriKind.Relative);
+ var resp = await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync(url.ToString(), request);
+ var token = await resp.Content.ReadFromJsonAsync<SynapseAdminRegistrationTokenListResult.SynapseAdminRegistrationTokenListResultToken>();
+ return token!;
+ }
+
+ public async Task<SynapseAdminRegistrationTokenListResult.SynapseAdminRegistrationTokenListResultToken> UpdateRegistrationTokenAsync(string token,
+ SynapseAdminRegistrationTokenUpdateRequest request) {
+ var url = new Uri($"/_synapse/admin/v1/registration_tokens/{token.UrlEncode()}", UriKind.Relative);
+ var resp = await authenticatedHomeserver.ClientHttpClient.PutAsJsonAsync(url.ToString(), request);
+ return await resp.Content.ReadFromJsonAsync<SynapseAdminRegistrationTokenListResult.SynapseAdminRegistrationTokenListResultToken>();
+ }
+
+ public async Task DeleteRegistrationTokenAsync(string token) {
+ var url = new Uri($"/_synapse/admin/v1/registration_tokens/{token.UrlEncode()}", UriKind.Relative);
+ await authenticatedHomeserver.ClientHttpClient.DeleteAsync(url.ToString());
+ }
+
+#endregion
+
+#region Account Validity
+
+ // Does anyone even use this?
+ // README: https://github.com/matrix-org/synapse/issues/15271
+ // -> Don't implement unless requested, if not for this feature almost never being used.
+
+#endregion
+
+#region Experimental Features
+
+ public async Task<Dictionary<string, bool>> GetExperimentalFeaturesAsync(string userId) {
+ var url = new Uri($"/_synapse/admin/v1/experimental_features/{userId.UrlEncode()}", UriKind.Relative);
+ var resp = await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<JsonObject>(url.ToString());
+ return resp["features"]!.GetValue<Dictionary<string, bool>>();
+ }
+
+ public async Task SetExperimentalFeaturesAsync(string userId, Dictionary<string, bool> features) {
+ var url = new Uri($"/_synapse/admin/v1/experimental_features/{userId.UrlEncode()}", UriKind.Relative);
+ await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync<JsonObject>(url.ToString(), new JsonObject {
+ ["features"] = JsonSerializer.Deserialize<JsonObject>(features.ToJson())
+ });
+ }
+
+#endregion
+
+#region Media
+
+ public async Task<SynapseAdminRoomMediaListResult> GetRoomMediaAsync(string roomId) {
+ var url = new Uri($"/_synapse/admin/v1/room/{roomId.UrlEncode()}/media", UriKind.Relative);
+ return await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRoomMediaListResult>(url.ToString());
+ }
+
+ // This is in the user admin API section
+ // public async IAsyncEnumerable<SynapseAdminRoomMediaListResult>
+
+#endregion
+
+ public async Task<SynapseAdminUserRedactIdResponse?> DeleteAllMessages(string mxid, List<string>? rooms = null, string? reason = null, int? limit = 100000,
+ bool waitForCompletion = true) {
+ rooms ??= [];
+
+ Dictionary<string, object> payload = new();
+ if (rooms.Count > 0) payload["rooms"] = rooms;
+ if (!string.IsNullOrEmpty(reason)) payload["reason"] = reason;
+ if (limit.HasValue) payload["limit"] = limit.Value;
+
+ var redactIdResp = await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync($"/_synapse/admin/v1/user/{mxid}/redact", payload);
+ var redactId = await redactIdResp.Content.ReadFromJsonAsync<SynapseAdminUserRedactIdResponse>();
+
+ if (waitForCompletion) {
+ while (true) {
+ var status = await GetRedactStatus(redactId!.RedactionId);
+ if (status?.Status != "pending") break;
+ await Task.Delay(1000);
+ }
+ }
+
+ return redactId;
+ }
+
+ public async Task<SynapseAdminRedactStatusResponse?> GetRedactStatus(string redactId) {
+ return await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRedactStatusResponse>(
+ $"/_synapse/admin/v1/user/redact_status/{redactId}");
+ }
+
+ public async Task DeactivateUserAsync(string mxid, bool erase = false, bool eraseMessages = false, bool extraCleanup = false) {
+ if (eraseMessages) {
+ await DeleteAllMessages(mxid);
+ }
+
+ if (extraCleanup) {
+ await UserCleanupExecutor.CleanupUser(mxid);
+ }
+
+ await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync($"/_synapse/admin/v1/deactivate", new { erase });
+ }
+
+ public async Task ResetPasswordAsync(string mxid, string newPassword, bool logoutDevices = false) {
+ await authenticatedHomeserver.ClientHttpClient.PostAsJsonAsync($"/_synapse/admin/v1/reset_password/{mxid}",
+ new { new_password = newPassword, logout_devices = logoutDevices });
+ }
+
+ public async Task<SynapseAdminUserMediaResult> GetUserMediaAsync(string mxid, int? limit = 100, string? from = null, string? orderBy = null, string? dir = null) {
+ var url = $"/_synapse/admin/v1/users/{mxid}/media";
+ if (limit.HasValue) url += $"?limit={limit}";
+ if (!string.IsNullOrEmpty(from)) url += $"&from={from}";
+ if (!string.IsNullOrEmpty(orderBy)) url += $"&order_by={orderBy}";
+ if (!string.IsNullOrEmpty(dir)) url += $"&dir={dir}";
+ return await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminUserMediaResult>(url);
+ }
+
+ public async IAsyncEnumerable<SynapseAdminUserMediaResult.MediaInfo> GetUserMediaEnumerableAsync(string mxid, int chunkSize = 100, string? orderBy = null, string? dir = null) {
+ SynapseAdminUserMediaResult? res = null;
+ do {
+ res = await GetUserMediaAsync(mxid, chunkSize, res?.NextToken, orderBy, dir);
+ foreach (var media in res.Media) {
+ yield return media;
+ }
+ } while (!string.IsNullOrEmpty(res.NextToken));
+ }
+
+ public async Task BlockRoom(string roomId, bool block = true) {
+ await authenticatedHomeserver.ClientHttpClient.PutAsJsonAsync($"/_synapse/admin/v1/rooms/{roomId}/block", new {
+ block
+ });
+ }
+
+ public async Task<SynapseAdminRoomDeleteResponse> DeleteRoom(string roomId, SynapseAdminRoomDeleteRequest request, bool waitForCompletion = true) {
+ var resp = await authenticatedHomeserver.ClientHttpClient.DeleteAsJsonAsync($"/_synapse/admin/v2/rooms/{roomId}", request);
+ var deleteResp = await resp.Content.ReadFromJsonAsync<SynapseAdminRoomDeleteResponse>();
+
+ if (waitForCompletion) {
+ while (true) {
+ var status = await GetRoomDeleteStatus(deleteResp!.DeleteId);
+ if (status?.Status != "pending") break;
+ await Task.Delay(1000);
+ }
+ }
+
+ return deleteResp!;
+ }
+
+ public async Task<SynapseAdminRoomDeleteStatus> GetRoomDeleteStatusByRoomId(string roomId) {
+ return await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRoomDeleteStatus>(
+ $"/_synapse/admin/v2/rooms/{roomId}/delete_status");
+ }
+
+ public async Task<SynapseAdminRoomDeleteStatus> GetRoomDeleteStatus(string deleteId) {
+ return await authenticatedHomeserver.ClientHttpClient.GetFromJsonAsync<SynapseAdminRoomDeleteStatus>(
+ $"/_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}/members");
+ }
+
+ public async Task QuarantineMediaByRoomId(string roomId) {
+ await authenticatedHomeserver.ClientHttpClient.PutAsJsonAsync($"/_synapse/admin/v1/room/{roomId}/media/quarantine", new { });
+ }
+
+ public async Task QuarantineMediaByUserId(string mxid) {
+ await authenticatedHomeserver.ClientHttpClient.PutAsJsonAsync($"/_synapse/admin/v1/user/{mxid}/media/quarantine", new { });
+ }
+
+ public async Task QuarantineMediaById(string serverName, string mediaId) {
+ await authenticatedHomeserver.ClientHttpClient.PutAsJsonAsync($"/_synapse/admin/v1/media/quarantine/{serverName}/{mediaId}", new { });
+ }
+
+ public async Task QuarantineMediaById(MxcUri mxcUri) {
+ await authenticatedHomeserver.ClientHttpClient.PutAsJsonAsync($"/_synapse/admin/v1/media/quarantine/{mxcUri.ServerName}/{mxcUri.MediaId}", new { });
+ }
+
+ public async Task DeleteMediaById(string serverName, string mediaId) {
+ await authenticatedHomeserver.ClientHttpClient.DeleteAsync($"/_synapse/admin/v1/media/{serverName}/{mediaId}");
+ }
+
+ public async Task DeleteMediaById(MxcUri mxcUri) {
+ await authenticatedHomeserver.ClientHttpClient.DeleteAsync($"/_synapse/admin/v1/media/{mxcUri.ServerName}/{mxcUri.MediaId}");
+ }
}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminUserCleanupExecutor.cs b/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminUserCleanupExecutor.cs
new file mode 100644
index 0000000..6edf40c
--- /dev/null
+++ b/LibMatrix/Homeservers/ImplementationDetails/Synapse/SynapseAdminUserCleanupExecutor.cs
@@ -0,0 +1,27 @@
+namespace LibMatrix.Homeservers.ImplementationDetails.Synapse;
+
+public class SynapseAdminUserCleanupExecutor(AuthenticatedHomeserverSynapse homeserver) {
+ /*
+ Remove mappings of SSO IDs
+ Delete media uploaded by user (included avatar images)
+ Delete sent and received messages
+ Remove the user's creation (registration) timestamp
+ Remove rate limit overrides
+ Remove from monthly active users
+ Remove user's consent information (consent version and timestamp)
+ */
+ public async Task CleanupUser(string mxid) {
+ // change the user's password to a random one
+ var newPassword = Guid.NewGuid().ToString();
+ await homeserver.Admin.ResetPasswordAsync(mxid, newPassword, true);
+ await homeserver.Admin.DeleteAllMessages(mxid);
+
+ }
+ private async Task RunUserTasks(string mxid) {
+ var auth = await homeserver.Admin.LoginUserAsync(mxid, TimeSpan.FromDays(1));
+ var userHs = new AuthenticatedHomeserverSynapse(homeserver.ServerName, homeserver.WellKnownUris, null, auth.AccessToken);
+ await userHs.Initialise();
+
+
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/LibMatrix.csproj b/LibMatrix/LibMatrix.csproj
index 4814a18..042d943 100644
--- a/LibMatrix/LibMatrix.csproj
+++ b/LibMatrix/LibMatrix.csproj
@@ -18,7 +18,7 @@
</ItemGroup>
<ItemGroup>
- <PackageReference Include="ArcaneLibs" Version="1.0.0-preview.20250208-191806" Condition="'$(Configuration)' == 'Release'" />
+ <PackageReference Include="ArcaneLibs" Version="1.0.0-preview.20250307-202359" Condition="'$(Configuration)' == 'Release'" />
<ProjectReference Include="..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj" Condition="'$(Configuration)' == 'Debug'"/>
</ItemGroup>
diff --git a/LibMatrix/MxcUri.cs b/LibMatrix/MxcUri.cs
index 02a8fa6..875ae53 100644
--- a/LibMatrix/MxcUri.cs
+++ b/LibMatrix/MxcUri.cs
@@ -3,9 +3,9 @@ using System.Diagnostics.CodeAnalysis;
namespace LibMatrix;
public class MxcUri {
- public required string ServerName;
- public required string MediaId;
-
+ public required string ServerName { get; set; }
+ public required string MediaId { get; set; }
+
public static MxcUri Parse([StringSyntax("Uri")] string mxcUri) {
if (!mxcUri.StartsWith("mxc://")) throw new ArgumentException("Matrix Content URIs must start with 'mxc://'", nameof(mxcUri));
var parts = mxcUri[6..].Split('/');
@@ -15,13 +15,13 @@ public class MxcUri {
MediaId = parts[1]
};
}
-
+
public static implicit operator MxcUri(string mxcUri) => Parse(mxcUri);
public static implicit operator string(MxcUri mxcUri) => $"mxc://{mxcUri.ServerName}/{mxcUri.MediaId}";
public static implicit operator (string, string)(MxcUri mxcUri) => (mxcUri.ServerName, mxcUri.MediaId);
public static implicit operator MxcUri((string serverName, string mediaId) mxcUri) => (mxcUri.serverName, mxcUri.mediaId);
// public override string ToString() => $"mxc://{ServerName}/{MediaId}";
-
+
public string ToDownloadUri(string? baseUrl = null, string? filename = null, int? timeout = null) {
var uri = $"{baseUrl}/_matrix/client/v1/media/download/{ServerName}/{MediaId}";
if (filename is not null) uri += $"/{filename}";
diff --git a/LibMatrix/RoomTypes/GenericRoom.cs b/LibMatrix/RoomTypes/GenericRoom.cs
index 8f1b56d..fb56f2e 100644
--- a/LibMatrix/RoomTypes/GenericRoom.cs
+++ b/LibMatrix/RoomTypes/GenericRoom.cs
@@ -143,7 +143,7 @@ public class GenericRoom {
var url = $"/_matrix/client/v3/rooms/{RoomId}/messages?dir={dir}";
if (!string.IsNullOrWhiteSpace(from)) url += $"&from={from}";
if (limit is not null) url += $"&limit={limit}";
- else if (!string.IsNullOrWhiteSpace(filter)) url += $"&filter={filter}";
+ if (!string.IsNullOrWhiteSpace(filter)) url += $"&filter={filter}";
var res = await Homeserver.ClientHttpClient.GetFromJsonAsync<MessagesResponse>(url);
return res;
diff --git a/LibMatrix/Services/HomeserverResolverService.cs b/LibMatrix/Services/HomeserverResolverService.cs
index fa75f1e..01b11cc 100644
--- a/LibMatrix/Services/HomeserverResolverService.cs
+++ b/LibMatrix/Services/HomeserverResolverService.cs
@@ -49,6 +49,7 @@ public class HomeserverResolverService {
ArgumentNullException.ThrowIfNull(homeserver);
_logger.LogTrace("Resolving client well-known: {homeserver}", homeserver);
ClientWellKnown? clientWellKnown = null;
+ 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");
@@ -80,6 +81,7 @@ public class HomeserverResolverService {
ArgumentNullException.ThrowIfNull(homeserver);
_logger.LogTrace($"Resolving server well-known: {homeserver}");
ServerWellKnown? serverWellKnown = null;
+ 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");
@@ -95,7 +97,7 @@ public class HomeserverResolverService {
_logger.LogInformation("Server well-known for {hs}: {json}", homeserver, serverWellKnown?.ToJson() ?? "null");
if (!string.IsNullOrWhiteSpace(serverWellKnown?.Homeserver)) {
- var resolved = serverWellKnown.Homeserver;
+ var resolved = serverWellKnown.Homeserver.TrimEnd('/');
if (resolved.StartsWith("https://") || resolved.StartsWith("http://"))
return resolved;
if (await _httpClient.CheckSuccessStatus($"https://{resolved}/_matrix/federation/v1/version"))
@@ -106,7 +108,7 @@ public class HomeserverResolverService {
}
// fallback: most servers host C2S and S2S on the same domain
- var clientUrl = await _tryResolveClientEndpoint(homeserver);
+ var clientUrl = (await _tryResolveClientEndpoint(homeserver)).TrimEnd('/');
if (clientUrl is not null && await _httpClient.CheckSuccessStatus($"{clientUrl}/_matrix/federation/v1/version"))
return clientUrl;
diff --git a/LibMatrix/Services/ServiceInstaller.cs b/LibMatrix/Services/ServiceInstaller.cs
index ecc3f09..5ffd43a 100644
--- a/LibMatrix/Services/ServiceInstaller.cs
+++ b/LibMatrix/Services/ServiceInstaller.cs
@@ -1,4 +1,5 @@
-using LibMatrix.Services.WellKnownResolvers;
+using LibMatrix.Services.WellKnownResolver;
+using LibMatrix.Services.WellKnownResolver.WellKnownResolvers;
using Microsoft.Extensions.DependencyInjection;
namespace LibMatrix.Services;
@@ -10,6 +11,10 @@ public static class ServiceInstaller {
//Add services
services.AddSingleton<ClientWellKnownResolver>();
+ services.AddSingleton<ServerWellKnownResolver>();
+ services.AddSingleton<SupportWellKnownResolver>();
+ if (!services.Any(x => x.ServiceType == typeof(WellKnownResolverConfiguration)))
+ services.AddSingleton<WellKnownResolverConfiguration>();
services.AddSingleton<WellKnownResolverService>();
// Legacy
services.AddSingleton<HomeserverResolverService>();
diff --git a/LibMatrix/Services/WellKnownResolver/WellKnownResolverConfiguration.cs b/LibMatrix/Services/WellKnownResolver/WellKnownResolverConfiguration.cs
new file mode 100644
index 0000000..26a4c43
--- /dev/null
+++ b/LibMatrix/Services/WellKnownResolver/WellKnownResolverConfiguration.cs
@@ -0,0 +1,49 @@
+using System.Text.Json.Serialization;
+
+namespace LibMatrix.Services.WellKnownResolver;
+
+public class WellKnownResolverConfiguration {
+ /// <summary>
+ /// Allow transparent downgrades to plaintext HTTP if HTTPS fails
+ /// Enabling this is unsafe!
+ /// </summary>
+ [JsonPropertyName("allow_http")]
+ public bool AllowHttp { get; set; } = false;
+
+ /// <summary>
+ /// Use DNS resolution if available, for resolving SRV records
+ /// </summary>
+ [JsonPropertyName("allow_dns")]
+ public bool AllowDns { get; set; } = true;
+
+ /// <summary>
+ /// Use system resolver(s) if empty
+ /// </summary>
+ [JsonPropertyName("dns_servers")]
+ public List<string> DnsServers { get; set; } = new();
+
+ /// <summary>
+ /// Same as AllowDns, but for DNS over HTTPS - useful in browser contexts
+ /// </summary>
+ [JsonPropertyName("allow_doh")]
+ public bool AllowDoh { get; set; } = true;
+
+ /// <summary>
+ /// Use DNS over HTTPS - useful in browser contexts
+ /// Disabled if empty
+ /// </summary>
+ [JsonPropertyName("doh_servers")]
+ public List<string> DohServers { get; set; } = new();
+
+ /// <summary>
+ /// Whether to allow fallback subdomain lookups
+ /// </summary>
+ [JsonPropertyName("allow_fallback_subdomains")]
+ public bool AllowFallbackSubdomains { get; set; } = true;
+
+ /// <summary>
+ /// Fallback subdomains to try if the homeserver is not found
+ /// </summary>
+ [JsonPropertyName("fallback_subdomains")]
+ public List<string> FallbackSubdomains { get; set; } = ["matrix", "chat", "im"];
+}
\ No newline at end of file
diff --git a/LibMatrix/Services/WellKnownResolver/WellKnownResolverService.cs b/LibMatrix/Services/WellKnownResolver/WellKnownResolverService.cs
new file mode 100644
index 0000000..4c78347
--- /dev/null
+++ b/LibMatrix/Services/WellKnownResolver/WellKnownResolverService.cs
@@ -0,0 +1,91 @@
+using System.Diagnostics;
+using System.Text.Json.Serialization;
+using LibMatrix.Extensions;
+using LibMatrix.Services.WellKnownResolver.WellKnownResolvers;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace LibMatrix.Services.WellKnownResolver;
+
+public class WellKnownResolverService {
+ private readonly MatrixHttpClient _httpClient = new();
+
+ private readonly ILogger<WellKnownResolverService> _logger;
+ private readonly ClientWellKnownResolver _clientWellKnownResolver;
+ private readonly SupportWellKnownResolver _supportWellKnownResolver;
+ private readonly ServerWellKnownResolver _serverWellKnownResolver;
+ private readonly WellKnownResolverConfiguration _configuration;
+
+ public WellKnownResolverService(ILogger<WellKnownResolverService> logger, ClientWellKnownResolver clientWellKnownResolver, SupportWellKnownResolver supportWellKnownResolver,
+ WellKnownResolverConfiguration configuration, ServerWellKnownResolver serverWellKnownResolver) {
+ _logger = logger;
+ _clientWellKnownResolver = clientWellKnownResolver;
+ _supportWellKnownResolver = supportWellKnownResolver;
+ _configuration = configuration;
+ _serverWellKnownResolver = serverWellKnownResolver;
+ if (logger is NullLogger<WellKnownResolverService>) {
+ var stackFrame = new StackTrace(true).GetFrame(1);
+ Console.WriteLine(
+ $"WARN | Null logger provided to WellKnownResolverService!\n{stackFrame?.GetMethod()?.DeclaringType?.ToString() ?? "null"} at {stackFrame?.GetFileName() ?? "null"}:{stackFrame?.GetFileLineNumber().ToString() ?? "null"}");
+ }
+ }
+
+ public async Task<WellKnownRecords> TryResolveWellKnownRecords(string homeserver, bool includeClient = true, bool includeServer = true, bool includeSupport = true,
+ 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;
+ }
+
+ return records;
+ }
+
+ public class WellKnownRecords {
+ public WellKnownResolutionResult<ClientWellKnown?>? ClientWellKnown { get; set; }
+ public WellKnownResolutionResult<ServerWellKnown?>? ServerWellKnown { get; set; }
+ public WellKnownResolutionResult<SupportWellKnown?>? SupportWellKnown { get; set; }
+ }
+
+ public class WellKnownResolutionResult<T> {
+ public WellKnownSource Source { get; set; }
+ public string? SourceUri { get; set; }
+ public T? Content { get; set; }
+ public List<WellKnownResolutionWarning> Warnings { get; set; } = [];
+ }
+
+ [JsonConverter(typeof(JsonStringEnumConverter))]
+ public enum WellKnownSource {
+ None,
+ Https,
+ Dns,
+ Http,
+ ManualCheck,
+ Search
+ }
+
+ 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))]
+ public enum WellKnownResolutionWarningType {
+ None,
+ Exception,
+ InvalidResponse,
+ Timeout,
+ SlowResponse
+ }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/BaseWellKnownResolver.cs b/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/BaseWellKnownResolver.cs
new file mode 100644
index 0000000..cbe5b0a
--- /dev/null
+++ b/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/BaseWellKnownResolver.cs
@@ -0,0 +1,52 @@
+using System.Diagnostics;
+using System.Net.Http.Json;
+using ArcaneLibs.Collections;
+using LibMatrix.Extensions;
+
+namespace LibMatrix.Services.WellKnownResolver.WellKnownResolvers;
+
+public class BaseWellKnownResolver<T> where T : class, new() {
+ internal static readonly SemaphoreCache<WellKnownResolverService.WellKnownResolutionResult<T>> WellKnownCache = new() {
+ StoreNulls = false
+ };
+
+ internal static readonly MatrixHttpClient HttpClient = new();
+
+ internal async Task<WellKnownResolverService.WellKnownResolutionResult<T>> TryGetWellKnownFromUrl(string url,
+ WellKnownResolverService.WellKnownSource source) {
+ var sw = Stopwatch.StartNew();
+ try {
+ var request = await HttpClient.GetAsync(url);
+ sw.Stop();
+ var result = new WellKnownResolverService.WellKnownResolutionResult<T> {
+ Content = await request.Content.ReadFromJsonAsync<T>(),
+ Source = source,
+ SourceUri = url,
+ Warnings = []
+ };
+
+ if (sw.ElapsedMilliseconds > 1000) {
+ // logger.LogWarning($"Support well-known resolution took {sw.ElapsedMilliseconds}ms: {url}");
+ result.Warnings.Add(new() {
+ Type = WellKnownResolverService.WellKnownResolutionWarning.WellKnownResolutionWarningType.SlowResponse,
+ Message = $"Well-known resolution took {sw.ElapsedMilliseconds}ms"
+ });
+ }
+
+ return result;
+ }
+ catch (Exception e) {
+ return new WellKnownResolverService.WellKnownResolutionResult<T> {
+ Source = source,
+ SourceUri = url,
+ Warnings = [
+ new() {
+ Exception = e,
+ Type = WellKnownResolverService.WellKnownResolutionWarning.WellKnownResolutionWarningType.Exception,
+ Message = e.Message
+ }
+ ]
+ };
+ }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/ClientWellKnownResolver.cs b/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/ClientWellKnownResolver.cs
new file mode 100644
index 0000000..f8de38d
--- /dev/null
+++ b/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/ClientWellKnownResolver.cs
@@ -0,0 +1,42 @@
+using System.Text.Json.Serialization;
+using ArcaneLibs.Collections;
+using LibMatrix.Extensions;
+using Microsoft.Extensions.Logging;
+using WellKnownType = LibMatrix.Services.WellKnownResolver.WellKnownResolvers.ClientWellKnown;
+using ResultType =
+ LibMatrix.Services.WellKnownResolver.WellKnownResolverService.WellKnownResolutionResult<LibMatrix.Services.WellKnownResolver.WellKnownResolvers.ClientWellKnown?>;
+
+namespace LibMatrix.Services.WellKnownResolver.WellKnownResolvers;
+
+public class ClientWellKnownResolver(ILogger<ClientWellKnownResolver> logger, WellKnownResolverConfiguration configuration)
+ : BaseWellKnownResolver<ClientWellKnown> {
+ private static readonly SemaphoreCache<WellKnownResolverService.WellKnownResolutionResult<ClientWellKnown>> ClientWellKnownCache = new() {
+ 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 () => {
+ logger.LogTrace($"Resolving client well-known: {homeserver}");
+
+ WellKnownResolverService.WellKnownResolutionResult<ClientWellKnown> result =
+ await TryGetWellKnownFromUrl($"https://{homeserver}/.well-known/matrix/client", WellKnownResolverService.WellKnownSource.Https);
+ if (result.Content != null) return result;
+
+
+ return result;
+ });
+ }
+}
+
+public class ClientWellKnown {
+ [JsonPropertyName("m.homeserver")]
+ public WellKnownHomeserver Homeserver { get; set; }
+
+ public class WellKnownHomeserver {
+ [JsonPropertyName("base_url")]
+ public required string BaseUrl { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/ServerWellKnownResolver.cs b/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/ServerWellKnownResolver.cs
new file mode 100644
index 0000000..a99185c
--- /dev/null
+++ b/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/ServerWellKnownResolver.cs
@@ -0,0 +1,38 @@
+using System.Text.Json.Serialization;
+using ArcaneLibs.Collections;
+using LibMatrix.Extensions;
+using Microsoft.Extensions.Logging;
+using WellKnownType = LibMatrix.Services.WellKnownResolver.WellKnownResolvers.ServerWellKnown;
+using ResultType =
+ LibMatrix.Services.WellKnownResolver.WellKnownResolverService.WellKnownResolutionResult<LibMatrix.Services.WellKnownResolver.WellKnownResolvers.ServerWellKnown?>;
+
+namespace LibMatrix.Services.WellKnownResolver.WellKnownResolvers;
+
+public class ServerWellKnownResolver(ILogger<ServerWellKnownResolver> logger, WellKnownResolverConfiguration configuration)
+ : BaseWellKnownResolver<ServerWellKnown> {
+ private static readonly SemaphoreCache<WellKnownResolverService.WellKnownResolutionResult<ServerWellKnown>> ClientWellKnownCache = new() {
+ 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 () => {
+ logger.LogTrace($"Resolving client well-known: {homeserver}");
+
+ WellKnownResolverService.WellKnownResolutionResult<ServerWellKnown> result =
+ 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; }
+}
\ No newline at end of file
diff --git a/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/SupportWellKnownResolver.cs b/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/SupportWellKnownResolver.cs
new file mode 100644
index 0000000..99313db
--- /dev/null
+++ b/LibMatrix/Services/WellKnownResolver/WellKnownResolvers/SupportWellKnownResolver.cs
@@ -0,0 +1,44 @@
+using System.Diagnostics;
+using System.Net.Http.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.Logging;
+using WellKnownType = LibMatrix.Services.WellKnownResolver.WellKnownResolvers.SupportWellKnown;
+using ResultType = LibMatrix.Services.WellKnownResolver.WellKnownResolverService.WellKnownResolutionResult<
+ LibMatrix.Services.WellKnownResolver.WellKnownResolvers.SupportWellKnown?
+>;
+
+namespace LibMatrix.Services.WellKnownResolver.WellKnownResolvers;
+
+public class SupportWellKnownResolver(ILogger<SupportWellKnownResolver> logger, WellKnownResolverConfiguration configuration) : BaseWellKnownResolver<WellKnownType> {
+ public Task<ResultType> TryResolveWellKnown(string homeserver, WellKnownResolverConfiguration? config = null) {
+ config ??= configuration;
+ return WellKnownCache.TryGetOrAdd(homeserver, async () => {
+ logger.LogTrace($"Resolving support well-known: {homeserver}");
+
+ ResultType result = await TryGetWellKnownFromUrl($"https://{homeserver}/.well-known/matrix/support", WellKnownResolverService.WellKnownSource.Https);
+ if (result.Content != null)
+ return result;
+
+ return null;
+ });
+ }
+}
+
+public class SupportWellKnown {
+ [JsonPropertyName("contacts")]
+ public List<WellKnownContact>? Contacts { get; set; }
+
+ [JsonPropertyName("support_page")]
+ public Uri? SupportPage { get; set; }
+
+ public class WellKnownContact {
+ [JsonPropertyName("email_address")]
+ public string? EmailAddress { get; set; }
+
+ [JsonPropertyName("matrix_id")]
+ public string? MatrixId { get; set; }
+
+ [JsonPropertyName("role")]
+ public required string Role { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/LibMatrix/Services/WellKnownResolverService.cs b/LibMatrix/Services/WellKnownResolverService.cs
deleted file mode 100644
index ab2660f..0000000
--- a/LibMatrix/Services/WellKnownResolverService.cs
+++ /dev/null
@@ -1,88 +0,0 @@
-using System.Diagnostics;
-using System.Text.Json.Serialization;
-using ArcaneLibs.Collections;
-using ArcaneLibs.Extensions;
-using LibMatrix.Extensions;
-using LibMatrix.Services.WellKnownResolvers;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Logging.Abstractions;
-
-namespace LibMatrix.Services;
-
-public class WellKnownResolverService {
- private readonly MatrixHttpClient _httpClient = new();
-
- private readonly ILogger<WellKnownResolverService> _logger;
-
- public WellKnownResolverService(ILogger<WellKnownResolverService> logger) {
- _logger = logger;
- if (logger is NullLogger<WellKnownResolverService>) {
- var stackFrame = new StackTrace(true).GetFrame(1);
- Console.WriteLine(
- $"WARN | Null logger provided to WellKnownResolverService!\n{stackFrame?.GetMethod()?.DeclaringType?.ToString() ?? "null"} at {stackFrame?.GetFileName() ?? "null"}:{stackFrame?.GetFileLineNumber().ToString() ?? "null"}");
- }
- }
-
- public async Task<WellKnownRecords> TryResolveWellKnownRecords(string homeserver) {
- WellKnownRecords records = new();
- _logger.LogDebug($"Resolving well-knowns for {homeserver}");
-
- return records;
- }
-
-
-
- public class ServerWellKnown {
- [JsonPropertyName("m.server")]
- public required string Homeserver { get; set; }
- }
-
- public class WellKnownRecords {
- public ClientWellKnownResolver.ClientWellKnown? ClientWellKnown { get; set; }
- public ServerWellKnown? ServerWellKnown { get; set; }
- public SupportWellKnownResolver.SupportWellKnown? SupportWellKnown { get; set; }
-
- /// <summary>
- /// Reports the source of the client well-known data.
- /// </summary>
- public WellKnownSource? ClientWellKnownSource { get; set; }
-
- /// <summary>
- /// Reports the source of the server well-known data.
- /// </summary>
- public WellKnownSource? ServerWellKnownSource { get; set; }
-
- /// <summary>
- /// Reports the source of the support well-known data.
- /// </summary>
- public WellKnownSource? SupportWellKnownSource { get; set; }
- }
-
- public struct WellKnownResolutionResult<T> {
- public WellKnownResolverService.WellKnownSource Source { get; set; }
- public T WellKnown { get; set; }
- public List<WellKnownResolverService.WellKnownResolutionWarning> Warnings { get; set; }
- }
-
- public enum WellKnownSource {
- None,
- Https,
- Dns,
- Http,
- ManualCheck,
- Search
- }
-
- public struct WellKnownResolutionWarning {
- public WellKnownResolutionWarningType Type { get; set; }
- public string Message { get; set; }
- public Exception? Exception { get; set; }
-
- public enum WellKnownResolutionWarningType {
- None,
- Exception,
- InvalidResponse,
- Timeout
- }
- }
-}
\ No newline at end of file
diff --git a/LibMatrix/Services/WellKnownResolvers/ClientWellKnownResolver.cs b/LibMatrix/Services/WellKnownResolvers/ClientWellKnownResolver.cs
deleted file mode 100644
index d4d0166..0000000
--- a/LibMatrix/Services/WellKnownResolvers/ClientWellKnownResolver.cs
+++ /dev/null
@@ -1,53 +0,0 @@
-using System.Text.Json.Serialization;
-using ArcaneLibs.Collections;
-using LibMatrix.Extensions;
-using Microsoft.Extensions.Logging;
-
-namespace LibMatrix.Services.WellKnownResolvers;
-
-public class ClientWellKnownResolver(ILogger<ClientWellKnownResolver> logger) {
- private static readonly SemaphoreCache<WellKnownResolutionResult> ClientWellKnownCache = new() {
- StoreNulls = false
- };
- private static readonly MatrixHttpClient HttpClient = new();
-
- public Task<WellKnownResolutionResult> TryResolveClientWellKnown(string homeserver) {
- return ClientWellKnownCache.TryGetOrAdd(homeserver, async () => {
- logger.LogTrace($"Resolving client well-known: {homeserver}");
- if ((await TryGetClientWellKnownFromHttps(homeserver)) is { } clientWellKnown)
- return new() {
- Source = WellKnownResolverService.WellKnownSource.Https,
- WellKnown = clientWellKnown
- };
-
- return default!;
- });
- }
-
- private async Task<ClientWellKnown?> TryGetClientWellKnownFromHttps(string homeserver) {
- try {
- return await HttpClient.TryGetFromJsonAsync<ClientWellKnown>($"https://{homeserver}/.well-known/matrix/client");
- }
- catch {
- return null;
- }
- }
-
-
-
- public class ClientWellKnown {
- [JsonPropertyName("m.homeserver")]
- public required WellKnownHomeserver Homeserver { get; set; }
-
- public class WellKnownHomeserver {
- [JsonPropertyName("base_url")]
- public required string BaseUrl { get; set; }
- }
- }
-
- public struct WellKnownResolutionResult {
- public WellKnownResolverService.WellKnownSource Source { get; set; }
- public ClientWellKnown WellKnown { get; set; }
- public List<WellKnownResolverService.WellKnownResolutionWarning> Warnings { get; set; }
- }
-}
\ No newline at end of file
diff --git a/LibMatrix/Services/WellKnownResolvers/SupportWellKnownResolver.cs b/LibMatrix/Services/WellKnownResolvers/SupportWellKnownResolver.cs
deleted file mode 100644
index 1d7567a..0000000
--- a/LibMatrix/Services/WellKnownResolvers/SupportWellKnownResolver.cs
+++ /dev/null
@@ -1,54 +0,0 @@
-using System.Text.Json.Serialization;
-using ArcaneLibs.Collections;
-using LibMatrix.Extensions;
-using Microsoft.Extensions.Logging;
-
-namespace LibMatrix.Services.WellKnownResolvers;
-
-public class SupportWellKnownResolver(ILogger<SupportWellKnownResolver> logger) {
- private static readonly SemaphoreCache<WellKnownResolverService.WellKnownResolutionResult<SupportWellKnown>> ClientWellKnownCache = new() {
- StoreNulls = false
- };
-
- private static readonly MatrixHttpClient HttpClient = new();
-
- public Task<WellKnownResolverService.WellKnownResolutionResult<SupportWellKnown>> TryResolveClientWellKnown(string homeserver) {
- return ClientWellKnownCache.TryGetOrAdd(homeserver, async () => {
- logger.LogTrace($"Resolving client well-known: {homeserver}");
- if ((await TryGetClientWellKnownFromHttps(homeserver)) is { } clientWellKnown)
- return new() {
- Source = WellKnownResolverService.WellKnownSource.Https,
- WellKnown = clientWellKnown
- };
- return default!;
- });
- }
-
- private async Task<SupportWellKnown?> TryGetClientWellKnownFromHttps(string homeserver) {
- try {
- return await HttpClient.TryGetFromJsonAsync<SupportWellKnown>($"https://{homeserver}/.well-known/matrix/support");
- }
- catch {
- return null;
- }
- }
-
- public struct SupportWellKnown {
- [JsonPropertyName("contacts")]
- public List<WellKnownContact>? Contacts { get; set; }
-
- [JsonPropertyName("support_page")]
- public Uri? SupportPage { get; set; }
-
- public class WellKnownContact {
- [JsonPropertyName("email_address")]
- public string? EmailAddress { get; set; }
-
- [JsonPropertyName("matrix_id")]
- public string? MatrixId { get; set; }
-
- [JsonPropertyName("role")]
- public required string Role { get; set; }
- }
- }
-}
\ No newline at end of file
diff --git a/Tests/LibMatrix.Tests/Tests/HomeserverResolverTests/ClientWellKnownResolverTests.cs b/Tests/LibMatrix.Tests/Tests/HomeserverResolverTests/ClientWellKnownResolverTests.cs
index f6dfd97..ea494fa 100644
--- a/Tests/LibMatrix.Tests/Tests/HomeserverResolverTests/ClientWellKnownResolverTests.cs
+++ b/Tests/LibMatrix.Tests/Tests/HomeserverResolverTests/ClientWellKnownResolverTests.cs
@@ -1,5 +1,5 @@
using LibMatrix.Services;
-using LibMatrix.Services.WellKnownResolvers;
+using LibMatrix.Services.WellKnownResolver.WellKnownResolvers;
using LibMatrix.Tests.Fixtures;
using Xunit.Abstractions;
using Xunit.Microsoft.DependencyInjection.Abstracts;
@@ -18,16 +18,16 @@ public class ClientWellKnownResolverTests : TestBed<TestFixture> {
[Fact]
public async Task ResolveServerClient() {
var tasks = _config.ExpectedHomeserverClientMappings.Select(async mapping => {
- var server = await _resolver.TryResolveClientWellKnown(mapping.Key);
- Assert.Equal(mapping.Value, server.WellKnown.Homeserver.BaseUrl);
+ var server = await _resolver.TryResolveWellKnown(mapping.Key);
+ Assert.Equal(mapping.Value, server.Content.Homeserver.BaseUrl);
return server;
}).ToList();
await Task.WhenAll(tasks);
}
private async Task AssertClientWellKnown(string homeserver, string expected) {
- var server = await _resolver.TryResolveClientWellKnown(homeserver);
- Assert.Equal(expected, server.WellKnown.Homeserver.BaseUrl);
+ var server = await _resolver.TryResolveWellKnown(homeserver);
+ Assert.Equal(expected, server.Content.Homeserver.BaseUrl);
}
[Fact]
diff --git a/Utilities/LibMatrix.DevTestBot/LibMatrix.DevTestBot.csproj b/Utilities/LibMatrix.DevTestBot/LibMatrix.DevTestBot.csproj
index 8817e9c..7897e28 100644
--- a/Utilities/LibMatrix.DevTestBot/LibMatrix.DevTestBot.csproj
+++ b/Utilities/LibMatrix.DevTestBot/LibMatrix.DevTestBot.csproj
@@ -18,7 +18,7 @@
</PropertyGroup>
<ItemGroup>
- <PackageReference Include="ArcaneLibs.StringNormalisation" Version="1.0.0-preview.20250208-191806" Condition="'$(Configuration)' == 'Release'" />
+ <PackageReference Include="ArcaneLibs.StringNormalisation" Version="1.0.0-preview.20250307-202359" Condition="'$(Configuration)' == 'Release'" />
<ProjectReference Include="..\..\ArcaneLibs\ArcaneLibs.StringNormalisation\ArcaneLibs.StringNormalisation.csproj" Condition="'$(Configuration)' == 'Debug'"/>
<ProjectReference Include="..\..\LibMatrix\LibMatrix.csproj"/>
</ItemGroup>
|