about summary refs log tree commit diff
path: root/LibMatrix
diff options
context:
space:
mode:
Diffstat (limited to 'LibMatrix')
-rw-r--r--LibMatrix/Extensions/CanonicalJsonSerializer.cs91
-rw-r--r--LibMatrix/Extensions/EnumerableExtensions.cs90
-rw-r--r--LibMatrix/Extensions/MatrixHttpClient.Single.cs68
-rw-r--r--LibMatrix/Extensions/UnicodeJsonEncoder.cs173
-rw-r--r--LibMatrix/Helpers/MessageBuilder.cs12
-rw-r--r--LibMatrix/Helpers/SyncHelper.cs31
-rw-r--r--LibMatrix/Helpers/SyncStateResolver.cs293
-rw-r--r--LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs125
-rw-r--r--LibMatrix/Homeservers/RemoteHomeServer.cs3
-rw-r--r--LibMatrix/LibMatrix.csproj22
-rw-r--r--LibMatrix/Responses/DeviceKeysUploadRequest.cs24
-rw-r--r--LibMatrix/Responses/SyncResponse.cs12
-rw-r--r--LibMatrix/Responses/UserDirectoryResponse.cs30
-rw-r--r--LibMatrix/Responses/UserProfileResponse.cs15
-rw-r--r--LibMatrix/RoomTypes/GenericRoom.cs65
-rw-r--r--LibMatrix/RoomTypes/SpaceRoom.cs4
-rw-r--r--LibMatrix/Services/ServiceInstaller.cs10
-rw-r--r--LibMatrix/StateEvent.cs72
18 files changed, 873 insertions, 267 deletions
diff --git a/LibMatrix/Extensions/CanonicalJsonSerializer.cs b/LibMatrix/Extensions/CanonicalJsonSerializer.cs
new file mode 100644

index 0000000..a6fbcf4 --- /dev/null +++ b/LibMatrix/Extensions/CanonicalJsonSerializer.cs
@@ -0,0 +1,91 @@ +using System.Collections.Frozen; +using System.Reflection; +using System.Security.Cryptography; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using System.Text.Unicode; +using ArcaneLibs.Extensions; + +namespace LibMatrix.Extensions; + +public static class CanonicalJsonSerializer { + // TODO: Alphabetise dictionaries + private static JsonSerializerOptions _options => new() { + WriteIndented = false, + Encoder = UnicodeJsonEncoder.Singleton, + }; + + private static readonly FrozenSet<PropertyInfo> JsonSerializerOptionsProperties = typeof(JsonSerializerOptions) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(x => x.SetMethod != null && x.GetMethod != null) + .ToFrozenSet(); + + private static JsonSerializerOptions MergeOptions(JsonSerializerOptions? inputOptions) { + var newOptions = _options; + if (inputOptions == null) + return newOptions; + + foreach (var property in JsonSerializerOptionsProperties) { + if(property.Name == nameof(JsonSerializerOptions.Encoder)) + continue; + if (property.Name == nameof(JsonSerializerOptions.WriteIndented)) + continue; + + var value = property.GetValue(inputOptions); + // if (value == null) + // continue; + property.SetValue(newOptions, value); + } + + return newOptions; + } + +#region STJ API + + public static String Serialize<TValue>(TValue value, JsonSerializerOptions? options = null) { + var newOptions = MergeOptions(options); + + return System.Text.Json.JsonSerializer.SerializeToNode(value, options) // We want to allow passing custom converters for eg. double/float -> string here... + .SortProperties()! + .CanonicalizeNumbers()! + .ToJsonString(newOptions); + + + // System.Text.Json.JsonSerializer.SerializeToNode(System.Text.Json.JsonSerializer.Deserialize<dynamic>("{\n \"a\": -0,\n \"b\": 1e10\n}")).ToJsonString(); + + } + + public static String Serialize(object value, Type inputType, JsonSerializerOptions? options = null) => JsonSerializer.Serialize(value, inputType, _options); + // public static String Serialize<TValue>(TValue value, JsonTypeInfo<TValue> jsonTypeInfo) => JsonSerializer.Serialize(value, jsonTypeInfo, _options); + // public static String Serialize(Object value, JsonTypeInfo jsonTypeInfo) + +#endregion + + private static partial class JsonExtensions { + public static Action<JsonTypeInfo> AlphabetizeProperties(Type type) { + return typeInfo => { + if (typeInfo.Kind != JsonTypeInfoKind.Object || !type.IsAssignableFrom(typeInfo.Type)) + return; + AlphabetizeProperties()(typeInfo); + }; + } + + public static Action<JsonTypeInfo> AlphabetizeProperties() { + return static typeInfo => { + if (typeInfo.Kind == JsonTypeInfoKind.Dictionary) { } + + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + var properties = typeInfo.Properties.OrderBy(p => p.Name, StringComparer.Ordinal).ToList(); + typeInfo.Properties.Clear(); + for (int i = 0; i < properties.Count; i++) { + properties[i].Order = i; + typeInfo.Properties.Add(properties[i]); + } + }; + } + } +} \ No newline at end of file diff --git a/LibMatrix/Extensions/EnumerableExtensions.cs b/LibMatrix/Extensions/EnumerableExtensions.cs
index 42d9491..ace2c0c 100644 --- a/LibMatrix/Extensions/EnumerableExtensions.cs +++ b/LibMatrix/Extensions/EnumerableExtensions.cs
@@ -1,29 +1,91 @@ +using System.Collections.Frozen; +using System.Collections.Immutable; + namespace LibMatrix.Extensions; public static class EnumerableExtensions { + public static int insertions = 0; + public static int replacements = 0; + public static void MergeStateEventLists(this IList<StateEvent> oldState, IList<StateEvent> newState) { - foreach (var stateEvent in newState) { - var old = oldState.FirstOrDefault(x => x.Type == stateEvent.Type && x.StateKey == stateEvent.StateKey); - if (old is null) { - oldState.Add(stateEvent); - continue; + // foreach (var stateEvent in newState) { + // var old = oldState.FirstOrDefault(x => x.Type == stateEvent.Type && x.StateKey == stateEvent.StateKey); + // if (old is null) { + // oldState.Add(stateEvent); + // continue; + // } + // + // oldState.Remove(old); + // oldState.Add(stateEvent); + // } + + foreach (var e in newState) { + switch (FindIndex(e)) { + case -1: + oldState.Add(e); + break; + case var index: + oldState[index] = e; + break; } + } - oldState.Remove(old); - oldState.Add(stateEvent); + int FindIndex(StateEvent needle) { + for (int i = 0; i < oldState.Count; i++) { + var old = oldState[i]; + if (old.Type == needle.Type && old.StateKey == needle.StateKey) + return i; + } + + return -1; } } public static void MergeStateEventLists(this IList<StateEventResponse> oldState, IList<StateEventResponse> newState) { - foreach (var stateEvent in newState) { - var old = oldState.FirstOrDefault(x => x.Type == stateEvent.Type && x.StateKey == stateEvent.StateKey); - if (old is null) { - oldState.Add(stateEvent); - continue; + foreach (var e in newState) { + switch (FindIndex(e)) { + case -1: + oldState.Add(e); + break; + case var index: + oldState[index] = e; + break; + } + } + + int FindIndex(StateEventResponse needle) { + for (int i = 0; i < oldState.Count; i++) { + var old = oldState[i]; + if (old.Type == needle.Type && old.StateKey == needle.StateKey) + return i; + } + + return -1; + } + } + + public static void MergeStateEventLists(this List<StateEventResponse> oldState, List<StateEventResponse> newState) { + foreach (var e in newState) { + switch (FindIndex(e)) { + case -1: + oldState.Add(e); + insertions++; + break; + case var index: + oldState[index] = e; + replacements++; + break; + } + } + + int FindIndex(StateEventResponse needle) { + for (int i = 0; i < oldState.Count; i++) { + var old = oldState[i]; + if (old.Type == needle.Type && old.StateKey == needle.StateKey) + return i; } - oldState.Remove(old); - oldState.Add(stateEvent); + return -1; } } } \ No newline at end of file diff --git a/LibMatrix/Extensions/MatrixHttpClient.Single.cs b/LibMatrix/Extensions/MatrixHttpClient.Single.cs
index 3c8aea4..0e6d467 100644 --- a/LibMatrix/Extensions/MatrixHttpClient.Single.cs +++ b/LibMatrix/Extensions/MatrixHttpClient.Single.cs
@@ -2,6 +2,7 @@ // #define SYNC_HTTPCLIENT // Only allow one request as a time, for debugging using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Net; using System.Net.Http.Headers; using System.Reflection; using System.Security.Cryptography.X509Certificates; @@ -14,7 +15,7 @@ using ArcaneLibs.Extensions; namespace LibMatrix.Extensions; #if SINGLE_HTTPCLIENT -// TODO: Add URI wrapper for +// TODO: Add URI wrapper for public class MatrixHttpClient { private static readonly HttpClient Client; @@ -27,7 +28,7 @@ public class MatrixHttpClient { }; Client = new HttpClient(handler) { DefaultRequestVersion = new Version(3, 0), - Timeout = TimeSpan.FromHours(1) + Timeout = TimeSpan.FromDays(1) }; } catch (PlatformNotSupportedException e) { @@ -50,6 +51,7 @@ public class MatrixHttpClient { internal SemaphoreSlim _rateLimitSemaphore { get; } = new(1, 1); #endif + private const bool LogRequests = true; public Dictionary<string, string> AdditionalQueryParameters { get; set; } = new(); public Uri? BaseAddress { get; set; } @@ -70,21 +72,31 @@ public class MatrixHttpClient { public async Task<HttpResponseMessage> SendUnhandledAsync(HttpRequestMessage request, CancellationToken cancellationToken) { if (request.RequestUri is null) throw new NullReferenceException("RequestUri is null"); - // if (!request.RequestUri.IsAbsoluteUri) - request.RequestUri = request.RequestUri.EnsureAbsolute(BaseAddress!); + // if (!request.RequestUri.IsAbsoluteUri) + request.RequestUri = request.RequestUri.EnsureAbsolute(BaseAddress!); var swWait = Stopwatch.StartNew(); #if SYNC_HTTPCLIENT await _rateLimitSemaphore.WaitAsync(cancellationToken); #endif + + Console.WriteLine($"Sending {request.Method} {BaseAddress}{request.RequestUri} ({Util.BytesToString(request.GetContentLength())})"); + + if (request.RequestUri is null) throw new NullReferenceException("RequestUri is null"); + if (!request.RequestUri.IsAbsoluteUri) request.RequestUri = new Uri(BaseAddress, request.RequestUri); swWait.Stop(); var swExec = Stopwatch.StartNew(); - + foreach (var (key, value) in AdditionalQueryParameters) request.RequestUri = request.RequestUri.AddQuery(key, value); - foreach (var (key, value) in DefaultRequestHeaders) request.Headers.Add(key, value); + foreach (var (key, value) in DefaultRequestHeaders) { + if (request.Headers.Contains(key)) continue; + request.Headers.Add(key, value); + } + request.Options.Set(new HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingResponse"), true); - Console.WriteLine("Sending " + request.Summarise(includeHeaders:true, includeQuery: true, includeContentIfText: true)); - + if (LogRequests) + Console.WriteLine("Sending " + request.Summarise(includeHeaders: true, includeQuery: true, includeContentIfText: true, hideHeaders: ["Accept"])); + HttpResponseMessage? responseMessage; try { responseMessage = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); @@ -101,19 +113,25 @@ public class MatrixHttpClient { #endif // Console.WriteLine($"Sending {request.Method} {request.RequestUri} ({Util.BytesToString(request.Content?.Headers.ContentLength ?? 0)}) -> {(int)responseMessage.StatusCode} {responseMessage.StatusCode} ({Util.BytesToString(responseMessage.GetContentLength())}, WAIT={swWait.ElapsedMilliseconds}ms, EXEC={swExec.ElapsedMilliseconds}ms)"); - Console.WriteLine("Received " + responseMessage.Summarise(includeHeaders: true, includeContentIfText: false, hideHeaders: [ - "Server", - "Date", - "Transfer-Encoding", - "Connection", - "Vary", - "Content-Length", - "Access-Control-Allow-Origin", - "Access-Control-Allow-Methods", - "Access-Control-Allow-Headers", - "Access-Control-Expose-Headers", - "Cache-Control" - ])); + if (LogRequests) + Console.WriteLine("Received " + responseMessage.Summarise(includeHeaders: true, includeContentIfText: false, hideHeaders: [ + "Server", + "Date", + "Transfer-Encoding", + "Connection", + "Vary", + "Content-Length", + "Access-Control-Allow-Origin", + "Access-Control-Allow-Methods", + "Access-Control-Allow-Headers", + "Access-Control-Expose-Headers", + "Cache-Control", + "Cross-Origin-Resource-Policy", + "X-Content-Security-Policy", + "Referrer-Policy", + "X-Robots-Tag", + "Content-Security-Policy" + ])); return responseMessage; } @@ -122,6 +140,12 @@ public class MatrixHttpClient { var responseMessage = await SendUnhandledAsync(request, cancellationToken); if (responseMessage.IsSuccessStatusCode) return responseMessage; + //retry on gateway timeout + if (responseMessage.StatusCode == HttpStatusCode.GatewayTimeout) { + request.ResetSendStatus(); + return await SendAsync(request, cancellationToken); + } + //error handling var content = await responseMessage.Content.ReadAsStringAsync(cancellationToken); if (content.Length == 0) @@ -248,4 +272,4 @@ public class MatrixHttpClient { await SendAsync(request); } } -#endif \ No newline at end of file +#endif diff --git a/LibMatrix/Extensions/UnicodeJsonEncoder.cs b/LibMatrix/Extensions/UnicodeJsonEncoder.cs new file mode 100644
index 0000000..ae58263 --- /dev/null +++ b/LibMatrix/Extensions/UnicodeJsonEncoder.cs
@@ -0,0 +1,173 @@ +// LibMatrix: File sourced from https://github.com/dotnet/runtime/pull/87147/files under the MIT license. + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using System.Text.Encodings.Web; + +namespace LibMatrix.Extensions; + +internal sealed class UnicodeJsonEncoder : JavaScriptEncoder +{ + internal static readonly UnicodeJsonEncoder Singleton = new UnicodeJsonEncoder(); + + private readonly bool _preferHexEscape; + private readonly bool _preferUppercase; + + public UnicodeJsonEncoder() + : this(preferHexEscape: false, preferUppercase: false) + { + } + + public UnicodeJsonEncoder(bool preferHexEscape, bool preferUppercase) + { + _preferHexEscape = preferHexEscape; + _preferUppercase = preferUppercase; + } + + public override int MaxOutputCharactersPerInputCharacter => 6; // "\uXXXX" for a single char ("\uXXXX\uYYYY" [12 chars] for supplementary scalar value) + + public override unsafe int FindFirstCharacterToEncode(char* text, int textLength) + { + for (int index = 0; index < textLength; ++index) + { + char value = text[index]; + + if (NeedsEncoding(value)) + { + return index; + } + } + + return -1; + } + + public override unsafe bool TryEncodeUnicodeScalar(int unicodeScalar, char* buffer, int bufferLength, out int numberOfCharactersWritten) + { + bool encode = WillEncode(unicodeScalar); + + if (!encode) + { + Span<char> span = new Span<char>(buffer, bufferLength); + int spanWritten; + bool succeeded = new Rune(unicodeScalar).TryEncodeToUtf16(span, out spanWritten); + numberOfCharactersWritten = spanWritten; + return succeeded; + } + + if (!_preferHexEscape && unicodeScalar <= char.MaxValue && HasTwoCharacterEscape((char)unicodeScalar)) + { + if (bufferLength < 2) + { + numberOfCharactersWritten = 0; + return false; + } + + buffer[0] = '\\'; + buffer[1] = GetTwoCharacterEscapeSuffix((char)unicodeScalar); + numberOfCharactersWritten = 2; + return true; + } + else + { + if (bufferLength < 6) + { + numberOfCharactersWritten = 0; + return false; + } + + buffer[0] = '\\'; + buffer[1] = 'u'; + buffer[2] = '0'; + buffer[3] = '0'; + buffer[4] = ToHexDigit((unicodeScalar & 0xf0) >> 4, _preferUppercase); + buffer[5] = ToHexDigit(unicodeScalar & 0xf, _preferUppercase); + numberOfCharactersWritten = 6; + return true; + } + } + + public override bool WillEncode(int unicodeScalar) + { + if (unicodeScalar > char.MaxValue) + { + return false; + } + + return NeedsEncoding((char)unicodeScalar); + } + + // https://datatracker.ietf.org/doc/html/rfc8259#section-7 + private static bool NeedsEncoding(char value) + { + if (value == '"' || value == '\\') + { + return true; + } + + return value <= '\u001f'; + } + + private static bool HasTwoCharacterEscape(char value) + { + // RFC 8259, Section 7, "char = " BNF + switch (value) + { + case '"': + case '\\': + case '/': + case '\b': + case '\f': + case '\n': + case '\r': + case '\t': + return true; + default: + return false; + } + } + + private static char GetTwoCharacterEscapeSuffix(char value) + { + // RFC 8259, Section 7, "char = " BNF + switch (value) + { + case '"': + return '"'; + case '\\': + return '\\'; + case '/': + return '/'; + case '\b': + return 'b'; + case '\f': + return 'f'; + case '\n': + return 'n'; + case '\r': + return 'r'; + case '\t': + return 't'; + default: + throw new ArgumentOutOfRangeException(nameof(value)); + } + } + + private static char ToHexDigit(int value, bool uppercase) + { + if (value > 0xf) + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + + if (value < 10) + { + return (char)(value + '0'); + } + else + { + return (char)(value - 0xa + (uppercase ? 'A' : 'a')); + } + } +} \ No newline at end of file diff --git a/LibMatrix/Helpers/MessageBuilder.cs b/LibMatrix/Helpers/MessageBuilder.cs
index cfda6b3..d3bd6a5 100644 --- a/LibMatrix/Helpers/MessageBuilder.cs +++ b/LibMatrix/Helpers/MessageBuilder.cs
@@ -91,6 +91,18 @@ public class MessageBuilder(string msgType = "m.text", string format = "org.matr return this; } + public MessageBuilder WithMention(string id, string? displayName = null) { + Content.Body += $"@{displayName ?? id}"; + Content.FormattedBody += $"<a href=\"https://matrix.to/#/{id}\">{displayName ?? id}</a>"; + return this; + } + + public MessageBuilder WithNewline() { + Content.Body += "\n"; + Content.FormattedBody += "<br>"; + return this; + } + public MessageBuilder WithTable(Action<TableBuilder> tableBuilder) { var tb = new TableBuilder(this); WithHtmlTag("table", msb => tableBuilder(tb)); diff --git a/LibMatrix/Helpers/SyncHelper.cs b/LibMatrix/Helpers/SyncHelper.cs
index 7d5364b..f95d6f8 100644 --- a/LibMatrix/Helpers/SyncHelper.cs +++ b/LibMatrix/Helpers/SyncHelper.cs
@@ -1,10 +1,14 @@ using System.Diagnostics; using System.Net.Http.Json; +using System.Text.Json; +using ArcaneLibs.Collections; +using System.Text.Json.Nodes; using ArcaneLibs.Extensions; using LibMatrix.Filters; using LibMatrix.Homeservers; using LibMatrix.Interfaces.Services; using LibMatrix.Responses; +using LibMatrix.Utilities; using Microsoft.Extensions.Logging; namespace LibMatrix.Helpers; @@ -18,6 +22,7 @@ public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logg public string? Since { get; set; } public int Timeout { get; set; } = 30000; public string? SetPresence { get; set; } = "online"; + public bool UseInternalStreamingSync { get; set; } = true; public string? FilterId { get => _filterId; @@ -43,6 +48,7 @@ public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logg _filter = value; _filterIsDirty = true; _filterId = null; + _namedFilterName = null; } } @@ -110,13 +116,22 @@ public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logg // logger?.LogInformation("SyncHelper: Calling: {}", url); try { - var httpResp = await homeserver.ClientHttpClient.GetAsync(url, cancellationToken ?? CancellationToken.None); - if (httpResp is null) throw new NullReferenceException("Failed to send HTTP request"); - logger?.LogInformation("Got sync response: {} bytes, {} elapsed", httpResp.GetContentLength(), sw.Elapsed); - var deserializeSw = Stopwatch.StartNew(); - var resp = await httpResp.Content.ReadFromJsonAsync(cancellationToken: cancellationToken ?? CancellationToken.None, - jsonTypeInfo: SyncResponseSerializerContext.Default.SyncResponse); - logger?.LogInformation("Deserialized sync response: {} bytes, {} elapsed, {} total", httpResp.GetContentLength(), deserializeSw.Elapsed, sw.Elapsed); + SyncResponse? resp = null; + if (UseInternalStreamingSync) { + resp = await homeserver.ClientHttpClient.GetFromJsonAsync<SyncResponse>(url, cancellationToken: cancellationToken ?? CancellationToken.None); + logger?.LogInformation("Got sync response: ~{} bytes, {} elapsed", resp.ToJson(false, true, true).Length, sw.Elapsed); + } + else { + var httpResp = await homeserver.ClientHttpClient.GetAsync(url, cancellationToken ?? CancellationToken.None); + if (httpResp is null) throw new NullReferenceException("Failed to send HTTP request"); + logger?.LogInformation("Got sync response: {} bytes, {} elapsed", httpResp.GetContentLength(), sw.Elapsed); + var deserializeSw = Stopwatch.StartNew(); + // var jsonResp = await httpResp.Content.ReadFromJsonAsync<JsonObject>(cancellationToken: cancellationToken ?? CancellationToken.None); + // var resp = jsonResp.Deserialize<SyncResponse>(); + resp = await httpResp.Content.ReadFromJsonAsync(cancellationToken: cancellationToken ?? CancellationToken.None, + jsonTypeInfo: SyncResponseSerializerContext.Default.SyncResponse); + logger?.LogInformation("Deserialized sync response: {} bytes, {} elapsed, {} total", httpResp.GetContentLength(), deserializeSw.Elapsed, sw.Elapsed); + } var timeToWait = MinimumDelay.Subtract(sw.Elapsed); if (timeToWait.TotalMilliseconds > 0) @@ -242,4 +257,4 @@ public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logg if (logger is null) Console.WriteLine(message); else logger.LogInformation(message); } -} \ No newline at end of file +} diff --git a/LibMatrix/Helpers/SyncStateResolver.cs b/LibMatrix/Helpers/SyncStateResolver.cs
index 5e34628..282d26f 100644 --- a/LibMatrix/Helpers/SyncStateResolver.cs +++ b/LibMatrix/Helpers/SyncStateResolver.cs
@@ -1,6 +1,7 @@ using System.Collections.Frozen; using System.Collections.Immutable; using System.Diagnostics; +using System.Text; using ArcaneLibs.Extensions; using LibMatrix.Extensions; using LibMatrix.Filters; @@ -40,15 +41,16 @@ public class SyncStateResolver(AuthenticatedHomeserverGeneric homeserver, ILogge return (sync, MergedState); } - public async Task OptimiseStore() { + public async Task OptimiseStore(Action<int, int>? progressCallback = null) { if (storageProvider is null) return; if (!await storageProvider.ObjectExistsAsync("init")) return; var totalSw = Stopwatch.StartNew(); Console.Write("Optimising sync store..."); var initLoadTask = storageProvider.LoadObjectAsync<SyncResponse>("init"); - var keys = (await storageProvider.GetAllKeysAsync()).Where(x=>!x.StartsWith("old/")).ToFrozenSet(); + var keys = (await storageProvider.GetAllKeysAsync()).Where(x => !x.StartsWith("old/")).ToFrozenSet(); var count = keys.Count - 1; + int total = count; Console.WriteLine($"Found {count} entries to optimise."); var merged = await initLoadTask; @@ -64,6 +66,7 @@ public class SyncStateResolver(AuthenticatedHomeserverGeneric homeserver, ILogge var moveTasks = new List<Task>(); + Dictionary<string, Dictionary<string, TimeSpan>> traces = []; while (keys.Contains(merged.NextBatch)) { Console.Write($"Merging {merged.NextBatch}, {--count} remaining... "); var sw = Stopwatch.StartNew(); @@ -77,28 +80,36 @@ public class SyncStateResolver(AuthenticatedHomeserverGeneric homeserver, ILogge moveTasks.Add(storageProvider.MoveObjectAsync(merged.NextBatch, $"{oldPath}/{merged.NextBatch}")); Console.Write($"Move {sw.GetElapsedAndRestart().TotalMilliseconds}ms... "); - merged = MergeSyncs(merged, next); + var trace = new Dictionary<string, TimeSpan>(); + traces[merged.NextBatch] = trace; + merged = MergeSyncs(merged, next, trace); Console.Write($"Merge {sw.GetElapsedAndRestart().TotalMilliseconds}ms... "); Console.WriteLine($"Total {swt.Elapsed.TotalMilliseconds}ms"); // Console.WriteLine($"Merged {merged.NextBatch}, {--count} remaining..."); + progressCallback?.Invoke(count, total); } + var traceString = string.Join("\n", traces.Select(x => $"{x.Key}\t{x.Value.ToJson(indent: false)}")); + var ms = new MemoryStream(Encoding.UTF8.GetBytes(traceString)); + await storageProvider.SaveStreamAsync($"traces/{oldPath}", ms); + await storageProvider.SaveObjectAsync("init", merged); await Task.WhenAll(moveTasks); - + Console.WriteLine($"Optimised store in {totalSw.Elapsed.TotalMilliseconds}ms"); + Console.WriteLine($"Insertions: {EnumerableExtensions.insertions}, replacements: {EnumerableExtensions.replacements}"); } /// <summary> /// Remove all but initial sync and last checkpoint /// </summary> public async Task RemoveOldSnapshots() { - if(storageProvider is null) return; + if (storageProvider is null) return; var sw = Stopwatch.StartNew(); var map = await GetCheckpointMap(); if (map is null) return; - if(map.Count < 3) return; + if (map.Count < 3) return; var toRemove = map.Keys.Skip(1).Take(map.Count - 2).ToList(); Console.Write("Cleaning up old snapshots: "); @@ -109,6 +120,7 @@ public class SyncStateResolver(AuthenticatedHomeserverGeneric homeserver, ILogge await storageProvider?.DeleteObjectAsync(path); } } + Console.WriteLine("Done!"); Console.WriteLine($"Removed {toRemove.Count} old snapshots in {sw.Elapsed.TotalMilliseconds}ms"); } @@ -129,46 +141,6 @@ public class SyncStateResolver(AuthenticatedHomeserverGeneric homeserver, ILogge } public async Task dev() { - // var keys = (await storageProvider?.GetAllKeysAsync()).ToFrozenSet(); - // var times = new Dictionary<long, List<string>>(); - // var values = keys.Select(async x => Task.Run(async () => (x, await storageProvider?.LoadObjectAsync<SyncResponse>(x)))).ToAsyncEnumerable(); - // await foreach (var task in values) { - // var (key, data) = await task; - // if (data is null) continue; - // var derivTime = data.GetDerivedSyncTime(); - // if (!times.ContainsKey(derivTime)) times[derivTime] = new(); - // times[derivTime].Add(key); - // } - // - // foreach (var (time, ckeys) in times.OrderBy(x => x.Key)) { - // Console.WriteLine($"{time}: {ckeys.Count} keys"); - // } - - // var map = await GetCheckpointMap(); - // if (map is null) return; - // - // var times = new Dictionary<long, List<string>>(); - // foreach (var (time, keys) in map) { - // Console.WriteLine($"{time}: {keys.Count} keys - calculating times"); - // Dictionary<string, Task<SyncResponse?>?> tasks = keys.ToDictionary(x => x, x => storageProvider?.LoadObjectAsync<SyncResponse>(x)); - // var nextKey = "init"; - // long lastTime = 0; - // while (tasks.ContainsKey(nextKey)) { - // var data = await tasks[nextKey]; - // if (data is null) break; - // var derivTime = data.GetDerivedSyncTime(); - // if (derivTime == 0) derivTime = lastTime + 1; - // if (!times.ContainsKey(derivTime)) times[derivTime] = new(); - // times[derivTime].Add(nextKey); - // lastTime = derivTime; - // nextKey = data.NextBatch; - // } - // } - // - // foreach (var (time, ckeys) in times.OrderBy(x => x.Key)) { - // Console.WriteLine($"{time}: {ckeys.Count} keys"); - // } - int i = 0; var sw = Stopwatch.StartNew(); var hist = GetSerialisedHistory(); @@ -177,6 +149,7 @@ public class SyncStateResolver(AuthenticatedHomeserverGeneric homeserver, ILogge // Console.WriteLine($"[{++i}] {key} -> {resp.NextBatch} ({resp.GetDerivedSyncTime()})"); i++; } + Console.WriteLine($"Iterated {i} syncResponses in {sw.Elapsed}"); Environment.Exit(0); } @@ -228,7 +201,7 @@ public class SyncStateResolver(AuthenticatedHomeserverGeneric homeserver, ILogge if (resp.GetDerivedSyncTime() > unixTime) break; merged = MergeSyncs(merged, resp); } - + return merged; } @@ -240,8 +213,6 @@ public class SyncStateResolver(AuthenticatedHomeserverGeneric homeserver, ILogge if (!key.StartsWith("old/")) continue; var parts = key.Split('/'); if (parts.Length < 3) continue; - // if (!map.ContainsKey(parts[1])) map[parts[1]] = new(); - // map[parts[1]].Add(parts[2]); if (!ulong.TryParse(parts[1], out var checkpoint)) continue; if (!map.ContainsKey(checkpoint)) map[checkpoint] = new(); map[checkpoint].Add(parts[2]); @@ -250,29 +221,29 @@ public class SyncStateResolver(AuthenticatedHomeserverGeneric homeserver, ILogge return map.OrderBy(x => x.Key).ToImmutableSortedDictionary(x => x.Key, x => x.Value.ToFrozenSet()); } - private SyncResponse MergeSyncs(SyncResponse oldSync, SyncResponse newSync) { + private SyncResponse MergeSyncs(SyncResponse oldSync, SyncResponse newSync, Dictionary<string, TimeSpan>? trace = null) { + var sw = Stopwatch.StartNew(); oldSync.NextBatch = newSync.NextBatch ?? oldSync.NextBatch; - oldSync.AccountData ??= new EventList(); - oldSync.AccountData.Events ??= []; - if (newSync.AccountData?.Events is not null) - oldSync.AccountData.Events.MergeStateEventLists(newSync.AccountData?.Events ?? []); + oldSync.AccountData = MergeEventList(oldSync.AccountData, newSync.AccountData); + trace?.Add("AccountData", sw.GetElapsedAndRestart()); - oldSync.Presence ??= new(); - oldSync.Presence.Events?.ReplaceBy(newSync.Presence?.Events ?? [], (oldState, newState) => oldState.Sender == newState.Sender && oldState.Type == newState.Type); + oldSync.Presence = MergeEventListBy(oldSync.Presence, newSync.Presence, (oldState, newState) => oldState.Sender == newState.Sender && oldState.Type == newState.Type); + trace?.Add("Presence", sw.GetElapsedAndRestart()); + // TODO: can this be cleaned up? oldSync.DeviceOneTimeKeysCount ??= new(); if (newSync.DeviceOneTimeKeysCount is not null) foreach (var (key, value) in newSync.DeviceOneTimeKeysCount) oldSync.DeviceOneTimeKeysCount[key] = value; + trace?.Add("DeviceOneTimeKeysCount", sw.GetElapsedAndRestart()); if (newSync.Rooms is not null) - oldSync.Rooms = MergeRoomsDataStructure(oldSync.Rooms, newSync.Rooms); + oldSync.Rooms = MergeRoomsDataStructure(oldSync.Rooms, newSync.Rooms, trace); + trace?.Add("Rooms", sw.GetElapsedAndRestart()); - oldSync.ToDevice ??= new EventList(); - oldSync.ToDevice.Events ??= []; - if (newSync.ToDevice?.Events is not null) - oldSync.ToDevice.Events.MergeStateEventLists(newSync.ToDevice?.Events ?? []); + oldSync.ToDevice = MergeEventList(oldSync.ToDevice, newSync.ToDevice); + trace?.Add("ToDevice", sw.GetElapsedAndRestart()); oldSync.DeviceLists ??= new SyncResponse.DeviceListsDataStructure(); oldSync.DeviceLists.Changed ??= []; @@ -283,125 +254,171 @@ public class SyncStateResolver(AuthenticatedHomeserverGeneric homeserver, ILogge oldSync.DeviceLists.Changed.Add(s); } + trace?.Add("DeviceLists.Changed", sw.GetElapsedAndRestart()); + if (newSync.DeviceLists?.Left is not null) foreach (var s in newSync.DeviceLists.Left!) { oldSync.DeviceLists.Changed.Remove(s); oldSync.DeviceLists.Left.Add(s); } - return oldSync; - } - - private List<StateEventResponse>? MergePresenceEvents(List<StateEventResponse>? oldEvents, List<StateEventResponse>? newEvents) { - if (oldEvents is null) return newEvents; - if (newEvents is null) return oldEvents; + trace?.Add("DeviceLists.Left", sw.GetElapsedAndRestart()); - foreach (var newEvent in newEvents) { - oldEvents.RemoveAll(x => x.Sender == newEvent.Sender && x.Type == newEvent.Type); - oldEvents.Add(newEvent); - } - - return oldEvents; + return oldSync; } #region Merge rooms - private SyncResponse.RoomsDataStructure MergeRoomsDataStructure(SyncResponse.RoomsDataStructure? oldState, SyncResponse.RoomsDataStructure newState) { + private SyncResponse.RoomsDataStructure MergeRoomsDataStructure(SyncResponse.RoomsDataStructure? oldState, SyncResponse.RoomsDataStructure newState, + Dictionary<string, TimeSpan>? trace) { + var sw = Stopwatch.StartNew(); if (oldState is null) return newState; - oldState.Join ??= new Dictionary<string, SyncResponse.RoomsDataStructure.JoinedRoomDataStructure>(); - foreach (var (key, value) in newState.Join ?? new Dictionary<string, SyncResponse.RoomsDataStructure.JoinedRoomDataStructure>()) - if (!oldState.Join.ContainsKey(key)) oldState.Join[key] = value; - else oldState.Join[key] = MergeJoinedRoomDataStructure(oldState.Join[key], value); - - oldState.Invite ??= new Dictionary<string, SyncResponse.RoomsDataStructure.InvitedRoomDataStructure>(); - foreach (var (key, value) in newState.Invite ?? new Dictionary<string, SyncResponse.RoomsDataStructure.InvitedRoomDataStructure>()) - if (!oldState.Invite.ContainsKey(key)) oldState.Invite[key] = value; - else oldState.Invite[key] = MergeInvitedRoomDataStructure(oldState.Invite[key], value); - - oldState.Leave ??= new Dictionary<string, SyncResponse.RoomsDataStructure.LeftRoomDataStructure>(); - foreach (var (key, value) in newState.Leave ?? new Dictionary<string, SyncResponse.RoomsDataStructure.LeftRoomDataStructure>()) { - if (!oldState.Leave.ContainsKey(key)) oldState.Leave[key] = value; - else oldState.Leave[key] = MergeLeftRoomDataStructure(oldState.Leave[key], value); - if (oldState.Invite.ContainsKey(key)) oldState.Invite.Remove(key); - if (oldState.Join.ContainsKey(key)) oldState.Join.Remove(key); - } + + if (newState.Join is { Count: > 0 }) + if (oldState.Join is null) + oldState.Join = newState.Join; + else + foreach (var (key, value) in newState.Join) + if (!oldState.Join.TryAdd(key, value)) + oldState.Join[key] = MergeJoinedRoomDataStructure(oldState.Join[key], value, trace); + trace?.Add("MergeRoomsDataStructure.Join", sw.GetElapsedAndRestart()); + + if (newState.Invite is { Count: > 0 }) + if (oldState.Invite is null) + oldState.Invite = newState.Invite; + else + foreach (var (key, value) in newState.Invite) + if (!oldState.Invite.TryAdd(key, value)) + oldState.Invite[key] = MergeInvitedRoomDataStructure(oldState.Invite[key], value, trace); + trace?.Add("MergeRoomsDataStructure.Invite", sw.GetElapsedAndRestart()); + + if (newState.Leave is { Count: > 0 }) + if (oldState.Leave is null) + oldState.Leave = newState.Leave; + else + foreach (var (key, value) in newState.Leave) { + if (!oldState.Leave.TryAdd(key, value)) + oldState.Leave[key] = MergeLeftRoomDataStructure(oldState.Leave[key], value, trace); + if (oldState.Invite?.ContainsKey(key) ?? false) oldState.Invite.Remove(key); + if (oldState.Join?.ContainsKey(key) ?? false) oldState.Join.Remove(key); + } + trace?.Add("MergeRoomsDataStructure.Leave", sw.GetElapsedAndRestart()); return oldState; } private static SyncResponse.RoomsDataStructure.LeftRoomDataStructure MergeLeftRoomDataStructure(SyncResponse.RoomsDataStructure.LeftRoomDataStructure oldData, - SyncResponse.RoomsDataStructure.LeftRoomDataStructure newData) { - oldData.AccountData ??= new EventList(); - oldData.AccountData.Events ??= []; - oldData.Timeline ??= new SyncResponse.RoomsDataStructure.JoinedRoomDataStructure.TimelineDataStructure(); - oldData.Timeline.Events ??= []; - oldData.State ??= new EventList(); - oldData.State.Events ??= []; - - if (newData.AccountData?.Events is not null) - oldData.AccountData.Events.MergeStateEventLists(newData.AccountData?.Events ?? []); - - if (newData.Timeline?.Events is not null) - oldData.Timeline.Events.MergeStateEventLists(newData.Timeline?.Events ?? []); + SyncResponse.RoomsDataStructure.LeftRoomDataStructure newData, Dictionary<string, TimeSpan>? trace) { + var sw = Stopwatch.StartNew(); + + oldData.AccountData = MergeEventList(oldData.AccountData, newData.AccountData); + trace?.Add($"LeftRoomDataStructure.AccountData/{oldData.GetHashCode()}", sw.GetElapsedAndRestart()); + + oldData.Timeline = AppendEventList(oldData.Timeline, newData.Timeline) as SyncResponse.RoomsDataStructure.JoinedRoomDataStructure.TimelineDataStructure + ?? throw new InvalidOperationException("Merged room timeline was not TimelineDataStructure"); oldData.Timeline.Limited = newData.Timeline?.Limited ?? oldData.Timeline.Limited; oldData.Timeline.PrevBatch = newData.Timeline?.PrevBatch ?? oldData.Timeline.PrevBatch; + trace?.Add($"LeftRoomDataStructure.Timeline/{oldData.GetHashCode()}", sw.GetElapsedAndRestart()); - if (newData.State?.Events is not null) - oldData.State.Events.MergeStateEventLists(newData.State?.Events ?? []); + oldData.State = MergeEventList(oldData.State, newData.State); + trace?.Add($"LeftRoomDataStructure.State/{oldData.GetHashCode()}", sw.GetElapsedAndRestart()); return oldData; } private static SyncResponse.RoomsDataStructure.InvitedRoomDataStructure MergeInvitedRoomDataStructure(SyncResponse.RoomsDataStructure.InvitedRoomDataStructure oldData, - SyncResponse.RoomsDataStructure.InvitedRoomDataStructure newData) { - oldData.InviteState ??= new EventList(); - oldData.InviteState.Events ??= []; - if (newData.InviteState?.Events is not null) - oldData.InviteState.Events.MergeStateEventLists(newData.InviteState?.Events ?? []); + SyncResponse.RoomsDataStructure.InvitedRoomDataStructure newData, Dictionary<string, TimeSpan>? trace) { + var sw = Stopwatch.StartNew(); + oldData.InviteState = MergeEventList(oldData.InviteState, newData.InviteState); + trace?.Add($"InvitedRoomDataStructure.InviteState/{oldData.GetHashCode()}", sw.GetElapsedAndRestart()); return oldData; } private static SyncResponse.RoomsDataStructure.JoinedRoomDataStructure MergeJoinedRoomDataStructure(SyncResponse.RoomsDataStructure.JoinedRoomDataStructure oldData, - SyncResponse.RoomsDataStructure.JoinedRoomDataStructure newData) { - oldData.AccountData ??= new EventList(); - oldData.AccountData.Events ??= []; - oldData.Timeline ??= new SyncResponse.RoomsDataStructure.JoinedRoomDataStructure.TimelineDataStructure(); - oldData.Timeline.Events ??= []; - oldData.State ??= new EventList(); - oldData.State.Events ??= []; - oldData.Ephemeral ??= new EventList(); - oldData.Ephemeral.Events ??= []; - - if (newData.AccountData?.Events is not null) - oldData.AccountData.Events.MergeStateEventLists(newData.AccountData?.Events ?? []); - - if (newData.Timeline?.Events is not null) - oldData.Timeline.Events.MergeStateEventLists(newData.Timeline?.Events ?? []); + SyncResponse.RoomsDataStructure.JoinedRoomDataStructure newData, Dictionary<string, TimeSpan>? trace) { + var sw = Stopwatch.StartNew(); + + oldData.AccountData = MergeEventList(oldData.AccountData, newData.AccountData); + trace?.Add($"JoinedRoomDataStructure.AccountData/{oldData.GetHashCode()}", sw.GetElapsedAndRestart()); + + oldData.Timeline = AppendEventList(oldData.Timeline, newData.Timeline) as SyncResponse.RoomsDataStructure.JoinedRoomDataStructure.TimelineDataStructure + ?? throw new InvalidOperationException("Merged room timeline was not TimelineDataStructure"); oldData.Timeline.Limited = newData.Timeline?.Limited ?? oldData.Timeline.Limited; oldData.Timeline.PrevBatch = newData.Timeline?.PrevBatch ?? oldData.Timeline.PrevBatch; + trace?.Add($"JoinedRoomDataStructure.Timeline/{oldData.GetHashCode()}", sw.GetElapsedAndRestart()); - if (newData.State?.Events is not null) - oldData.State.Events.MergeStateEventLists(newData.State?.Events ?? []); + oldData.State = MergeEventList(oldData.State, newData.State); + trace?.Add($"JoinedRoomDataStructure.State/{oldData.GetHashCode()}", sw.GetElapsedAndRestart()); - if (newData.Ephemeral?.Events is not null) - oldData.Ephemeral.Events.MergeStateEventLists(newData.Ephemeral?.Events ?? []); + oldData.Ephemeral = MergeEventList(oldData.Ephemeral, newData.Ephemeral); + trace?.Add($"JoinedRoomDataStructure.Ephemeral/{oldData.GetHashCode()}", sw.GetElapsedAndRestart()); oldData.UnreadNotifications ??= new SyncResponse.RoomsDataStructure.JoinedRoomDataStructure.UnreadNotificationsDataStructure(); oldData.UnreadNotifications.HighlightCount = newData.UnreadNotifications?.HighlightCount ?? oldData.UnreadNotifications.HighlightCount; oldData.UnreadNotifications.NotificationCount = newData.UnreadNotifications?.NotificationCount ?? oldData.UnreadNotifications.NotificationCount; + trace?.Add($"JoinedRoom$DataStructure.UnreadNotifications/{oldData.GetHashCode()}", sw.GetElapsedAndRestart()); + + if (oldData.Summary is null) + oldData.Summary = newData.Summary; + else { + oldData.Summary.Heroes = newData.Summary?.Heroes ?? oldData.Summary.Heroes; + oldData.Summary.JoinedMemberCount = newData.Summary?.JoinedMemberCount ?? oldData.Summary.JoinedMemberCount; + oldData.Summary.InvitedMemberCount = newData.Summary?.InvitedMemberCount ?? oldData.Summary.InvitedMemberCount; + } - oldData.Summary ??= new SyncResponse.RoomsDataStructure.JoinedRoomDataStructure.SummaryDataStructure { - Heroes = newData.Summary?.Heroes ?? oldData.Summary.Heroes, - JoinedMemberCount = newData.Summary?.JoinedMemberCount ?? oldData.Summary.JoinedMemberCount, - InvitedMemberCount = newData.Summary?.InvitedMemberCount ?? oldData.Summary.InvitedMemberCount - }; - oldData.Summary.Heroes = newData.Summary?.Heroes ?? oldData.Summary.Heroes; - oldData.Summary.JoinedMemberCount = newData.Summary?.JoinedMemberCount ?? oldData.Summary.JoinedMemberCount; - oldData.Summary.InvitedMemberCount = newData.Summary?.InvitedMemberCount ?? oldData.Summary.InvitedMemberCount; + trace?.Add($"JoinedRoomDataStructure.Summary/{oldData.GetHashCode()}", sw.GetElapsedAndRestart()); return oldData; } #endregion + + private static EventList? MergeEventList(EventList? oldState, EventList? newState) { + if (newState is null) return oldState; + if (oldState is null) { + return newState; + } + + if (newState.Events is null) return oldState; + if (oldState.Events is null) { + oldState.Events = newState.Events; + return oldState; + } + + oldState.Events.MergeStateEventLists(newState.Events); + return oldState; + } + + private static EventList? MergeEventListBy(EventList? oldState, EventList? newState, Func<StateEventResponse, StateEventResponse, bool> comparer) { + if (newState is null) return oldState; + if (oldState is null) { + return newState; + } + + if (newState.Events is null) return oldState; + if (oldState.Events is null) { + oldState.Events = newState.Events; + return oldState; + } + + oldState.Events.ReplaceBy(newState.Events, comparer); + return oldState; + } + + private static EventList? AppendEventList(EventList? oldState, EventList? newState) { + if (newState is null) return oldState; + if (oldState is null) { + return newState; + } + + if (newState.Events is null) return oldState; + if (oldState.Events is null) { + oldState.Events = newState.Events; + return oldState; + } + + oldState.Events.AddRange(newState.Events); + return oldState; + } } \ No newline at end of file diff --git a/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs b/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs
index c729a44..77a72c8 100644 --- a/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs +++ b/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs
@@ -48,7 +48,6 @@ public class AuthenticatedHomeserverGeneric : RemoteHomeserver { public HsNamedCaches NamedCaches { get; set; } = null!; public GenericRoom GetRoom(string roomId) { - if (roomId is null || !roomId.StartsWith("!")) throw new ArgumentException("Room ID must start with !", nameof(roomId)); return new GenericRoom(this, roomId); } @@ -186,6 +185,17 @@ public class AuthenticatedHomeserverGeneric : RemoteHomeserver { #endregion +#region MSC 4133 + + public async Task UpdateProfilePropertyAsync(string name, object? value) { + var caps = await GetCapabilitiesAsync(); + if(caps is null) throw new Exception("Failed to get capabilities"); + + } + +#endregion + + [Obsolete("This method assumes no support for MSC 4069 and MSC 4133")] public async Task UpdateProfileAsync(UserProfileResponse? newProfile, bool preserveCustomRoomProfile = true) { if (newProfile is null) return; Console.WriteLine($"Updating profile for {WhoAmI.UserId} to {newProfile.ToJson(ignoreNull: true)} (preserving room profiles: {preserveCustomRoomProfile})"); @@ -406,4 +416,115 @@ public class AuthenticatedHomeserverGeneric : RemoteHomeserver { public NamedFilterCache FilterCache { get; init; } public NamedFileCache FileCache { get; init; } } -} \ No newline at end of file + +#region Authenticated Media + + // TODO: implement /_matrix/client/v1/media/config when it's actually useful - https://spec.matrix.org/v1.11/client-server-api/#get_matrixclientv1mediaconfig + + private (string ServerName, string MediaId) ParseMxcUri(string mxcUri) { + if (!mxcUri.StartsWith("mxc://")) throw new ArgumentException("Matrix Content URIs must start with 'mxc://'", nameof(mxcUri)); + var parts = mxcUri[6..].Split('/'); + if (parts.Length != 2) throw new ArgumentException($"Invalid Matrix Content URI '{mxcUri}' passed! Matrix Content URIs must exist of only 2 parts!", nameof(mxcUri)); + return (parts[0], parts[1]); + } + + public async Task<Stream> GetMediaStreamAsync(string mxcUri, string? filename = null, int? timeout = null) { + var (serverName, mediaId) = ParseMxcUri(mxcUri); + try { + var uri = $"/_matrix/client/v1/media/download/{serverName}/{mediaId}"; + if (!string.IsNullOrWhiteSpace(filename)) uri += $"/{HttpUtility.UrlEncode(filename)}"; + if (timeout is not null) uri += $"?timeout_ms={timeout}"; + var res = await ClientHttpClient.GetAsync(uri); + return await res.Content.ReadAsStreamAsync(); + } + catch (MatrixException e) { + if (e is not { ErrorCode: "M_UNKNOWN" }) throw; + } + + //fallback to legacy media + try { + var uri = $"/_matrix/media/v1/download/{serverName}/{mediaId}"; + if (!string.IsNullOrWhiteSpace(filename)) uri += $"/{HttpUtility.UrlEncode(filename)}"; + if (timeout is not null) uri += $"?timeout_ms={timeout}"; + var res = await ClientHttpClient.GetAsync(uri); + return await res.Content.ReadAsStreamAsync(); + } + catch (MatrixException e) { + if (e is not { ErrorCode: "M_UNKNOWN" }) throw; + } + + throw new LibMatrixException() { + ErrorCode = LibMatrixException.ErrorCodes.M_UNSUPPORTED, + Error = "Failed to download media" + }; + // return default; + } + + public async Task<Stream> GetThumbnailStreamAsync(string mxcUri, int width, int height, string? method = null, int? timeout = null) { + var (serverName, mediaId) = ParseMxcUri(mxcUri); + try { + var uri = new Uri($"/_matrix/client/v1/thumbnail/{serverName}/{mediaId}"); + uri = uri.AddQuery("width", width.ToString()); + uri = uri.AddQuery("height", height.ToString()); + if (!string.IsNullOrWhiteSpace(method)) uri = uri.AddQuery("method", method); + if (timeout is not null) uri = uri.AddQuery("timeout_ms", timeout.ToString()); + + var res = await ClientHttpClient.GetAsync(uri.ToString()); + return await res.Content.ReadAsStreamAsync(); + } + catch (MatrixException e) { + if (e is not { ErrorCode: "M_UNKNOWN" }) throw; + } + + //fallback to legacy media + try { + var uri = new Uri($"/_matrix/media/v1/thumbnail/{serverName}/{mediaId}"); + uri = uri.AddQuery("width", width.ToString()); + uri = uri.AddQuery("height", height.ToString()); + if (!string.IsNullOrWhiteSpace(method)) uri = uri.AddQuery("method", method); + if (timeout is not null) uri = uri.AddQuery("timeout_ms", timeout.ToString()); + + var res = await ClientHttpClient.GetAsync(uri.ToString()); + return await res.Content.ReadAsStreamAsync(); + } + catch (MatrixException e) { + if (e is not { ErrorCode: "M_UNKNOWN" }) throw; + } + + throw new LibMatrixException() { + ErrorCode = LibMatrixException.ErrorCodes.M_UNSUPPORTED, + Error = "Failed to download media" + }; + // return default; + } + + public async Task<Dictionary<string, JsonValue>?> GetUrlPreviewAsync(string url) { + try { + var res = await ClientHttpClient.GetAsync($"/_matrix/client/v1/media/preview_url?url={HttpUtility.UrlEncode(url)}"); + return await res.Content.ReadFromJsonAsync<Dictionary<string, JsonValue>>(); + } + catch (MatrixException e) { + if (e is not { ErrorCode: "M_UNRECOGNIZED" }) throw; + } + + //fallback to legacy media + try { + var res = await ClientHttpClient.GetAsync($"/_matrix/media/v1/preview_url?url={HttpUtility.UrlEncode(url)}"); + return await res.Content.ReadFromJsonAsync<Dictionary<string, JsonValue>>(); + } + catch (MatrixException e) { + if (e is not { ErrorCode: "M_UNRECOGNIZED" }) throw; + } + + throw new LibMatrixException() { + ErrorCode = LibMatrixException.ErrorCodes.M_UNSUPPORTED, + Error = "Failed to download URL preview" + }; + } + +#endregion + private class CapabilitiesResponse { + [JsonPropertyName("capabilities")] + public Dictionary<string, object>? Capabilities { get; set; } + } +} diff --git a/LibMatrix/Homeservers/RemoteHomeServer.cs b/LibMatrix/Homeservers/RemoteHomeServer.cs
index ecf3e3a..adaac6d 100644 --- a/LibMatrix/Homeservers/RemoteHomeServer.cs +++ b/LibMatrix/Homeservers/RemoteHomeServer.cs
@@ -55,6 +55,8 @@ public class RemoteHomeserver { return data; } + + // TODO: Do we need to support retrieving individual profile properties? Is there any use for that besides just getting the full profile? public async Task<ClientVersionsResponse> GetClientVersionsAsync() { var resp = await ClientHttpClient.GetAsync($"/_matrix/client/versions"); @@ -107,6 +109,7 @@ public class RemoteHomeserver { #endregion + [Obsolete("This call uses the deprecated unauthenticated media endpoints, please switch to the relevant AuthenticatedHomeserver methods instead.", true)] public string? ResolveMediaUri(string? mxcUri) { if (mxcUri is null) return null; if (mxcUri.StartsWith("https://")) return mxcUri; diff --git a/LibMatrix/LibMatrix.csproj b/LibMatrix/LibMatrix.csproj
index d0511ea..b992ad6 100644 --- a/LibMatrix/LibMatrix.csproj +++ b/LibMatrix/LibMatrix.csproj
@@ -1,33 +1,25 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net8.0</TargetFramework> + <TargetFramework>net9.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> <LangVersion>preview</LangVersion> <Optimize>true</Optimize> <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> + <AllowUnsafeBlocks>true</AllowUnsafeBlocks> <!-- Required for UnicodeJsonEncoder... --> </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" /> - <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" /> + <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0"/> + <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0"/> + <ProjectReference Include="..\LibMatrix.EventTypes\LibMatrix.EventTypes.csproj"/> </ItemGroup> <ItemGroup> - <ProjectReference Condition="Exists('..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj')" Include="..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj"/> - <!-- This is dangerous, but eases development since locking the version will drift out of sync without noticing, - which causes build errors due to missing functions. - Using the NuGet version in development is annoying due to delays between pushing and being able to consume. - If you want to use a time-appropriate version of the library, recursively clone https://cgit.rory.gay/matrix/MatrixUtils.git - instead, since this will be locked by the MatrixUtils project, which contains both LibMatrix and ArcaneLibs as a submodule. --> - <PackageReference Condition="!Exists('..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj')" Include="ArcaneLibs" Version="*-preview*"/> - <ProjectReference Include="..\LibMatrix.EventTypes\LibMatrix.EventTypes.csproj"/> + <PackageReference Include="ArcaneLibs" Version="1.0.0-preview.20241122-053825" Condition="'$(Configuration)' == 'Release'"/> + <ProjectReference Include="..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj" Condition="'$(Configuration)' == 'Debug'"/> </ItemGroup> - <Target Name="ArcaneLibsNugetWarning" AfterTargets="AfterBuild"> - <Warning Text="ArcaneLibs is being referenced from NuGet, which is dangerous. Please read the warning in LibMatrix.csproj!" Condition="!Exists('..\ArcaneLibs\ArcaneLibs\ArcaneLibs.csproj')"/> - </Target> - </Project> diff --git a/LibMatrix/Responses/DeviceKeysUploadRequest.cs b/LibMatrix/Responses/DeviceKeysUploadRequest.cs new file mode 100644
index 0000000..c93c4c6 --- /dev/null +++ b/LibMatrix/Responses/DeviceKeysUploadRequest.cs
@@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace LibMatrix.Responses; + +public class DeviceKeysUploadRequest { + [JsonPropertyName("device_keys")] + public DeviceKeysSchema DeviceKeys { get; set; } + + + [JsonPropertyName("one_time_keys")] + public Dictionary<string, OneTimeKey> OneTimeKeys { get; set; } + + public class DeviceKeysSchema { + [JsonPropertyName("algorithms")] + public List<string> Algorithms { get; set; } + } + public class OneTimeKey { + [JsonPropertyName("key")] + public string Key { get; set; } + + [JsonPropertyName("signatures")] + public Dictionary<string, Dictionary<string, string>> Signatures { get; set; } + } +} \ No newline at end of file diff --git a/LibMatrix/Responses/SyncResponse.cs b/LibMatrix/Responses/SyncResponse.cs
index 2d3d3f8..a4391b7 100644 --- a/LibMatrix/Responses/SyncResponse.cs +++ b/LibMatrix/Responses/SyncResponse.cs
@@ -39,6 +39,10 @@ public class SyncResponse { } // supporting classes + public class PresenceDataStructure { + [JsonPropertyName("events")] + public List<StateEventResponse>? Events { get; set; } + } public class RoomsDataStructure { [JsonPropertyName("join")] @@ -86,7 +90,7 @@ public class SyncResponse { [JsonPropertyName("summary")] public SummaryDataStructure? Summary { get; set; } - public class TimelineDataStructure { + public class TimelineDataStructure : EventList { public TimelineDataStructure() { } public TimelineDataStructure(List<StateEventResponse>? events, bool? limited) { @@ -94,8 +98,8 @@ public class SyncResponse { Limited = limited; } - [JsonPropertyName("events")] - public List<StateEventResponse>? Events { get; set; } + // [JsonPropertyName("events")] + // public List<StateEventResponse>? Events { get; set; } [JsonPropertyName("prev_batch")] public string? PrevBatch { get; set; } @@ -140,4 +144,4 @@ public class SyncResponse { Rooms?.Leave?.Values?.Max(x => x.Timeline?.Events?.Max(y => y.OriginServerTs)) ?? 0 ]).Max(); } -} \ No newline at end of file +} diff --git a/LibMatrix/Responses/UserDirectoryResponse.cs b/LibMatrix/Responses/UserDirectoryResponse.cs new file mode 100644
index 0000000..13235d9 --- /dev/null +++ b/LibMatrix/Responses/UserDirectoryResponse.cs
@@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace LibMatrix.Responses; + +public class UserDirectoryResponse { + [JsonPropertyName("limited")] + public bool Limited { get; set; } + + [JsonPropertyName("results")] + public List<UserDirectoryResult> Results { get; set; } + + public class UserDirectoryResult { + [JsonPropertyName("avatar_url")] + public string? AvatarUrl { get; set; } + + [JsonPropertyName("display_name")] + public string? DisplayName { get; set; } + + [JsonPropertyName("user_id")] + public string UserId { get; set; } + } +} + +public class UserDirectoryRequest { + [JsonPropertyName("search_term")] + public string SearchTerm { get; set; } + + [JsonPropertyName("limit")] + public int? Limit { get; set; } +} \ No newline at end of file diff --git a/LibMatrix/Responses/UserProfileResponse.cs b/LibMatrix/Responses/UserProfileResponse.cs
index 6c9380f..30e4c32 100644 --- a/LibMatrix/Responses/UserProfileResponse.cs +++ b/LibMatrix/Responses/UserProfileResponse.cs
@@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Serialization; namespace LibMatrix.Responses; @@ -8,4 +9,18 @@ public class UserProfileResponse { [JsonPropertyName("displayname")] public string? DisplayName { get; set; } + + // MSC 4133 - Extending User Profile API with Key:Value pairs + [JsonExtensionData] + public Dictionary<string, JsonElement>? CustomKeys { get; set; } + + public JsonElement? this[string key] { + get => CustomKeys?[key]; + set { + if (value is null) + CustomKeys?.Remove(key); + else + (CustomKeys ??= [])[key] = value.Value; + } + } } \ No newline at end of file diff --git a/LibMatrix/RoomTypes/GenericRoom.cs b/LibMatrix/RoomTypes/GenericRoom.cs
index 4f6a4e9..84a3d30 100644 --- a/LibMatrix/RoomTypes/GenericRoom.cs +++ b/LibMatrix/RoomTypes/GenericRoom.cs
@@ -1,6 +1,7 @@ using System.Collections.Frozen; using System.Diagnostics; using System.Net.Http.Json; +using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; @@ -211,10 +212,11 @@ public class GenericRoom { public async Task<RoomIdResponse> JoinAsync(string[]? homeservers = null, string? reason = null, bool checkIfAlreadyMember = true) { if (checkIfAlreadyMember) try { - _ = await GetCreateEventAsync(); - return new RoomIdResponse { - RoomId = RoomId - }; + var ser = await GetStateEventOrNullAsync(RoomMemberEventContent.EventId, Homeserver.UserId); + if (ser?.TypedContent is RoomMemberEventContent { Membership: "join" }) + return new RoomIdResponse { + RoomId = RoomId + }; } catch { } //ignore @@ -317,12 +319,12 @@ public class GenericRoom { public Task<RoomPowerLevelEventContent?> GetPowerLevelsAsync() => GetStateAsync<RoomPowerLevelEventContent>("m.room.power_levels"); + [Obsolete("This method will be merged into GetNameAsync() in the future.")] public async Task<string> GetNameOrFallbackAsync(int maxMemberNames = 2) { try { var name = await GetNameAsync(); - if (!string.IsNullOrWhiteSpace(name)) - return name; - throw new Exception("No name"); + if (!string.IsNullOrEmpty(name)) return name; + throw new(); } catch { try { @@ -357,22 +359,6 @@ public class GenericRoom { return Task.WhenAll(tasks); } - public async Task<string?> GetResolvedRoomAvatarUrlAsync(bool useOriginHomeserver = false) { - var avatar = await GetAvatarUrlAsync(); - if (avatar?.Url is null) return null; - if (!avatar.Url.StartsWith("mxc://")) return avatar.Url; - if (useOriginHomeserver) - try { - var hs = avatar.Url.Split('/', 3)[1]; - return await new HomeserverResolverService(NullLogger<HomeserverResolverService>.Instance).ResolveMediaUri(hs, avatar.Url); - } - catch (Exception e) { - Console.WriteLine(e); - } - - return Homeserver.ResolveMediaUri(avatar.Url); - } - #endregion #region Simple calls @@ -393,12 +379,12 @@ public class GenericRoom { await Homeserver.ClientHttpClient.PostAsJsonAsync($"/_matrix/client/v3/rooms/{RoomId}/ban", new UserIdAndReason { UserId = userId, Reason = reason }); - public async Task UnbanAsync(string userId) => + public async Task UnbanAsync(string userId, string? reason = null) => await Homeserver.ClientHttpClient.PostAsJsonAsync($"/_matrix/client/v3/rooms/{RoomId}/unban", - new UserIdAndReason { UserId = userId }); + new UserIdAndReason { UserId = userId, Reason = reason }); public async Task InviteUserAsync(string userId, string? reason = null, bool skipExisting = true) { - if (skipExisting && await GetStateAsync<RoomMemberEventContent>("m.room.member", userId) is not null) + if (skipExisting && await GetStateOrNullAsync<RoomMemberEventContent>("m.room.member", userId) is not null) return; await Homeserver.ClientHttpClient.PostAsJsonAsync($"/_matrix/client/v3/rooms/{RoomId}/invite", new UserIdAndReason(userId, reason)); } @@ -412,7 +398,7 @@ public class GenericRoom { .Content.ReadFromJsonAsync<EventIdResponse>(); public async Task<EventIdResponse?> SendStateEventAsync(string eventType, string stateKey, object content) => - await (await Homeserver.ClientHttpClient.PutAsJsonAsync($"/_matrix/client/v3/rooms/{RoomId}/state/{eventType}/{stateKey}", content)) + await (await Homeserver.ClientHttpClient.PutAsJsonAsync($"/_matrix/client/v3/rooms/{RoomId}/state/{eventType.UrlEncode()}/{stateKey.UrlEncode()}", content)) .Content.ReadFromJsonAsync<EventIdResponse>(); public async Task<EventIdResponse> SendTimelineEventAsync(string eventType, TimelineEventContent content) { @@ -448,6 +434,16 @@ public class GenericRoom { return await res.Content.ReadFromJsonAsync<T>(); } + public async Task<T?> GetRoomAccountDataOrNullAsync<T>(string key) { + try { + return await GetRoomAccountDataAsync<T>(key); + } + catch (MatrixException e) { + if (e.ErrorCode == "M_NOT_FOUND") return default; + throw; + } + } + public async Task SetRoomAccountDataAsync(string key, object data) { var res = await Homeserver.ClientHttpClient.PutAsJsonAsync($"/_matrix/client/v3/user/{Homeserver.UserId}/rooms/{RoomId}/account_data/{key}", data); if (!res.IsSuccessStatusCode) { @@ -459,10 +455,17 @@ public class GenericRoom { public Task<StateEventResponse> GetEventAsync(string eventId) => Homeserver.ClientHttpClient.GetFromJsonAsync<StateEventResponse>($"/_matrix/client/v3/rooms/{RoomId}/event/{eventId}"); - public async Task<EventIdResponse> RedactEventAsync(string eventToRedact, string reason) { + public async Task<EventIdResponse> RedactEventAsync(string eventToRedact, string? reason = null) { var data = new { reason }; - return (await (await Homeserver.ClientHttpClient.PutAsJsonAsync( - $"/_matrix/client/v3/rooms/{RoomId}/redact/{eventToRedact}/{Guid.NewGuid()}", data)).Content.ReadFromJsonAsync<EventIdResponse>())!; + var url = $"/_matrix/client/v3/rooms/{RoomId}/redact/{eventToRedact}/{Guid.NewGuid().ToString()}"; + while (true) { + try { + return (await (await Homeserver.ClientHttpClient.PutAsJsonAsync(url, data)).Content.ReadFromJsonAsync<EventIdResponse>())!; + } catch (MatrixException e) { + if (e is { ErrorCode: MatrixException.ErrorCodes.M_FORBIDDEN }) throw; + throw; + } + } } #endregion @@ -553,4 +556,4 @@ public class GenericRoom { public class RoomIdResponse { [JsonPropertyName("room_id")] public string RoomId { get; set; } = null!; -} \ No newline at end of file +} diff --git a/LibMatrix/RoomTypes/SpaceRoom.cs b/LibMatrix/RoomTypes/SpaceRoom.cs
index b40ccc6..4563ed3 100644 --- a/LibMatrix/RoomTypes/SpaceRoom.cs +++ b/LibMatrix/RoomTypes/SpaceRoom.cs
@@ -4,6 +4,8 @@ using LibMatrix.Homeservers; namespace LibMatrix.RoomTypes; public class SpaceRoom(AuthenticatedHomeserverGeneric homeserver, string roomId) : GenericRoom(homeserver, roomId) { + public const string TypeName = "m.space"; + public async IAsyncEnumerable<GenericRoom> GetChildrenAsync(bool includeRemoved = false) { // var rooms = new List<GenericRoom>(); var state = GetFullStateAsync(); @@ -31,7 +33,7 @@ public class SpaceRoom(AuthenticatedHomeserverGeneric homeserver, string roomId) }); return resp; } - + public async Task<EventIdResponse> AddChildByIdAsync(string id) { return await AddChildAsync(Homeserver.GetRoom(id)); } diff --git a/LibMatrix/Services/ServiceInstaller.cs b/LibMatrix/Services/ServiceInstaller.cs
index 06ea9de..8b7e54b 100644 --- a/LibMatrix/Services/ServiceInstaller.cs +++ b/LibMatrix/Services/ServiceInstaller.cs
@@ -5,23 +5,13 @@ namespace LibMatrix.Services; public static class ServiceInstaller { public static IServiceCollection AddRoryLibMatrixServices(this IServiceCollection services, RoryLibMatrixConfiguration? config = null) { - //Check required services - // if (!services.Any(x => x.ServiceType == typeof(TieredStorageService))) - // throw new Exception("[RMUCore/DI] No TieredStorageService has been registered!"); //Add config services.AddSingleton(config ?? new RoryLibMatrixConfiguration()); //Add services services.AddSingleton<HomeserverResolverService>(sp => new HomeserverResolverService(sp.GetRequiredService<ILogger<HomeserverResolverService>>())); - - // if (services.First(x => x.ServiceType == typeof(TieredStorageService)).Lifetime == ServiceLifetime.Singleton) { services.AddSingleton<HomeserverProviderService>(); - // } - // else { - // services.AddScoped<HomeserverProviderService>(); - // } - // services.AddScoped<MatrixHttpClient>(); return services; } } diff --git a/LibMatrix/StateEvent.cs b/LibMatrix/StateEvent.cs
index 869e420..dc76622 100644 --- a/LibMatrix/StateEvent.cs +++ b/LibMatrix/StateEvent.cs
@@ -31,23 +31,6 @@ public class StateEvent { public static Type GetStateEventType(string? type) => string.IsNullOrWhiteSpace(type) ? typeof(UnknownEventContent) : KnownStateEventTypesByName.GetValueOrDefault(type) ?? typeof(UnknownEventContent); - [JsonPropertyName("state_key")] - public string? StateKey { get; set; } - - [JsonPropertyName("type")] - public string Type { get; set; } - - [JsonPropertyName("replaces_state")] - public string? ReplacesState { get; set; } - - private JsonObject? _rawContent; - - [JsonPropertyName("content")] - public JsonObject? RawContent { - get => _rawContent; - set => _rawContent = value; - } - [JsonIgnore] public Type MappedType => GetStateEventType(Type); @@ -61,6 +44,7 @@ public class StateEvent { public string FriendlyTypeNamePlural => MappedType.GetFriendlyNamePluralOrNull() ?? Type; private static readonly JsonSerializerOptions TypedContentSerializerOptions = new() { + // We need these, NumberHandling covers other number types that we don't want to convert Converters = { new JsonFloatStringConverter(), new JsonDoubleStringConverter(), @@ -72,10 +56,6 @@ public class StateEvent { [SuppressMessage("ReSharper", "PropertyCanBeMadeInitOnly.Global")] public EventContent? TypedContent { get { - ClassCollector<EventContent>.ResolveFromAllAccessibleAssemblies(); - // if (Type == "m.receipt") { - // return null; - // } try { var mappedType = GetStateEventType(Type); if (mappedType == typeof(UnknownEventContent)) @@ -99,6 +79,35 @@ public class StateEvent { } } + public T? ContentAs<T>() { + try { + return RawContent.Deserialize<T>(TypedContentSerializerOptions)!; + } + catch (JsonException e) { + Console.WriteLine(e); + Console.WriteLine("Content:\n" + (RawContent?.ToJson() ?? "null")); + } + + return default; + } + + [JsonPropertyName("state_key")] + public string? StateKey { get; set; } + + [JsonPropertyName("type")] + public string Type { get; set; } + + [JsonPropertyName("replaces_state")] + public string? ReplacesState { get; set; } + + private JsonObject? _rawContent; + + [JsonPropertyName("content")] + public JsonObject? RawContent { + get => _rawContent; + set => _rawContent = value; + } + //debug [JsonIgnore] public string InternalSelfTypeName { @@ -224,4 +233,23 @@ public class StateEventContentPolymorphicTypeInfoResolver : DefaultJsonTypeInfoR } */ -#endregion \ No newline at end of file +#endregion + +/* +public class ForgivingObjectConverter<T> : JsonConverter<T> where T : new() { + public override T? Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions options) { + try { + var text = JsonDocument.ParseValue(ref reader).RootElement.GetRawText(); + return JsonSerializer.Deserialize<T>(text, options); + } + catch (JsonException ex) { + Console.WriteLine(ex); + return null; + } + } + + public override bool CanConvert(Type typeToConvert) => true; + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + => JsonSerializer.Serialize<T>(writer, value, options); +}*/