diff --git a/MxApiExtensions/Controllers/Client/ClientVersionsController.cs b/MxApiExtensions/Controllers/Client/ClientVersionsController.cs
new file mode 100644
index 0000000..d29e3b2
--- /dev/null
+++ b/MxApiExtensions/Controllers/Client/ClientVersionsController.cs
@@ -0,0 +1,52 @@
+using System.Net.Http.Headers;
+using LibMatrix.Responses;
+using Microsoft.AspNetCore.Mvc;
+using MxApiExtensions.Services;
+
+namespace MxApiExtensions.Controllers;
+
+[ApiController]
+[Route("/")]
+public class ClientVersionsController : ControllerBase {
+ private readonly ILogger _logger;
+ private readonly AuthenticatedHomeserverProviderService _authenticatedHomeserverProviderService;
+ private static Dictionary<string, string> _tokenMap = new();
+
+ public ClientVersionsController(ILogger<ClientVersionsController> logger, MxApiExtensionsConfiguration config, AuthenticationService authenticationService, AuthenticatedHomeserverProviderService authenticatedHomeserverProviderService) {
+ _logger = logger;
+ _authenticatedHomeserverProviderService = authenticatedHomeserverProviderService;
+ }
+
+ [HttpGet("/_matrix/client/versions")]
+ public async Task<ClientVersionsResponse> Proxy([FromQuery] string? access_token, string? _) {
+ var clientVersions = new ClientVersionsResponse {
+ Versions = new() {
+ "r0.0.1",
+ "r0.1.0",
+ "r0.2.0",
+ "r0.3.0",
+ "r0.4.0",
+ "r0.5.0",
+ "r0.6.0",
+ "r0.6.1",
+ "v1.1",
+ "v1.2",
+ "v1.3",
+ "v1.4",
+ "v1.5",
+ "v1.6"
+ },
+ UnstableFeatures = new()
+ };
+ try {
+ var hs = await _authenticatedHomeserverProviderService.GetHomeserver();
+ clientVersions = await hs.GetClientVersionsAsync();
+
+ _logger.LogInformation("Fetching client versions for {}: {}{}", hs.WhoAmI.UserId, Request.Path, Request.QueryString);
+ }
+ catch { }
+
+ clientVersions.UnstableFeatures.Add("gay.rory.mxapiextensions.v0", true);
+ return clientVersions;
+ }
+}
diff --git a/MxApiExtensions/Controllers/Client/LoginController.cs b/MxApiExtensions/Controllers/Client/LoginController.cs
new file mode 100644
index 0000000..009aaef
--- /dev/null
+++ b/MxApiExtensions/Controllers/Client/LoginController.cs
@@ -0,0 +1,73 @@
+using System.Net.Http.Headers;
+using ArcaneLibs.Extensions;
+using LibMatrix;
+using LibMatrix.Extensions;
+using LibMatrix.Responses;
+using LibMatrix.Services;
+using Microsoft.AspNetCore.Mvc;
+using MxApiExtensions.Classes.LibMatrix;
+using MxApiExtensions.Services;
+
+namespace MxApiExtensions.Controllers;
+
+[ApiController]
+[Route("/")]
+public class LoginController(ILogger<LoginController> logger, HomeserverProviderService hsProvider, HomeserverResolverService hsResolver, AuthenticationService auth,
+ MxApiExtensionsConfiguration conf)
+ : ControllerBase {
+ private readonly ILogger _logger = logger;
+ private readonly HomeserverProviderService _hsProvider = hsProvider;
+ private readonly MxApiExtensionsConfiguration _conf = conf;
+
+ [HttpPost("/_matrix/client/{_}/login")]
+ public async Task Proxy([FromBody] LoginRequest request, string _) {
+ string hsCanonical = null;
+ if (Request.Headers.Keys.Any(x => x.ToUpper() == "MXAE_UPSTREAM")) {
+ hsCanonical = Request.Headers.GetByCaseInsensitiveKey("MXAE_UPSTREAM")[0]!;
+ _logger.LogInformation("Found upstream: {}", hsCanonical);
+ }
+ else {
+ if (!request.Identifier.User.Contains("#")) {
+ Response.StatusCode = (int)StatusCodes.Status403Forbidden;
+ Response.ContentType = "application/json";
+ await Response.StartAsync();
+ await Response.WriteAsync(new MxApiMatrixException {
+ ErrorCode = "M_FORBIDDEN",
+ Error = "[MxApiExtensions] Invalid username, must be of the form @user#domain:" + Request.Host.Value
+ }.GetAsJson() ?? "");
+ await Response.CompleteAsync();
+ }
+
+ hsCanonical = request.Identifier.User.Split('#')[1].Split(':')[0];
+ request.Identifier.User = request.Identifier.User.Split(':')[0].Replace('#', ':');
+ if (!request.Identifier.User.StartsWith('@')) request.Identifier.User = '@' + request.Identifier.User;
+ }
+
+ var hs = await hsResolver.ResolveHomeserverFromWellKnown(hsCanonical);
+ //var hs = await _hsProvider.Login(hsCanonical, mxid, request.Password);
+ var hsClient = new MatrixHttpClient { BaseAddress = new Uri(hs.Client) };
+ //hsClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", hsClient.DefaultRequestHeaders.Authorization!.Parameter);
+ if (!string.IsNullOrWhiteSpace(request.InitialDeviceDisplayName))
+ request.InitialDeviceDisplayName += $" (via MxApiExtensions at {Request.Host.Value})";
+ var resp = await hsClient.PostAsJsonAsync("/_matrix/client/r0/login", request);
+ var loginResp = await resp.Content.ReadAsStringAsync();
+ Response.StatusCode = (int)resp.StatusCode;
+ Response.ContentType = resp.Content.Headers.ContentType?.ToString() ?? "application/json";
+ await Response.StartAsync();
+ await Response.WriteAsync(loginResp);
+ await Response.CompleteAsync();
+ var token = (await resp.Content.ReadFromJsonAsync<LoginResponse>())!.AccessToken;
+ await auth.SaveMxidForToken(token, request.Identifier.User);
+ }
+
+ [HttpGet("/_matrix/client/{_}/login")]
+ public async Task<object> Proxy(string? _) {
+ return new {
+ flows = new[] {
+ new {
+ type = "m.login.password"
+ }
+ }
+ };
+ }
+}
\ No newline at end of file
diff --git a/MxApiExtensions/Controllers/Client/Room/RoomsSendMessageController.cs b/MxApiExtensions/Controllers/Client/Room/RoomsSendMessageController.cs
new file mode 100644
index 0000000..3d1d4e2
--- /dev/null
+++ b/MxApiExtensions/Controllers/Client/Room/RoomsSendMessageController.cs
@@ -0,0 +1,18 @@
+using LibMatrix.Services;
+using Microsoft.AspNetCore.Mvc;
+using MxApiExtensions.Services;
+
+namespace MxApiExtensions.Controllers.Client.Room;
+
+[ApiController]
+[Route("/")]
+public class RoomController(ILogger<LoginController> logger, HomeserverResolverService hsResolver, AuthenticationService auth, MxApiExtensionsConfiguration conf,
+ AuthenticatedHomeserverProviderService hsProvider)
+ : ControllerBase {
+ [HttpGet("/_matrix/client/{_}/rooms/{roomId}/members_by_homeserver")]
+ public async Task<Dictionary<string, List<string>>> GetRoomMembersByHomeserver(string _, [FromRoute] string roomId, [FromQuery] bool joinedOnly = true) {
+ var hs = await hsProvider.GetHomeserver();
+ var room = hs.GetRoom(roomId);
+ return await room.GetMembersByHomeserverAsync();
+ }
+}
\ No newline at end of file
diff --git a/MxApiExtensions/Controllers/Client/RoomsSendMessageController.cs b/MxApiExtensions/Controllers/Client/RoomsSendMessageController.cs
new file mode 100644
index 0000000..6d3a774
--- /dev/null
+++ b/MxApiExtensions/Controllers/Client/RoomsSendMessageController.cs
@@ -0,0 +1,72 @@
+using System.Buffers.Text;
+using System.Net.Http.Headers;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using ArcaneLibs.Extensions;
+using LibMatrix;
+using LibMatrix.EventTypes.Spec;
+using LibMatrix.Extensions;
+using LibMatrix.Helpers;
+using LibMatrix.Homeservers;
+using LibMatrix.Responses;
+using LibMatrix.Services;
+using Microsoft.AspNetCore.Mvc;
+using MxApiExtensions.Classes;
+using MxApiExtensions.Classes.LibMatrix;
+using MxApiExtensions.Services;
+
+namespace MxApiExtensions.Controllers;
+
+[ApiController]
+[Route("/")]
+public class RoomsSendMessageController(ILogger<LoginController> logger, HomeserverResolverService hsResolver, AuthenticationService auth, MxApiExtensionsConfiguration conf,
+ AuthenticatedHomeserverProviderService hsProvider)
+ : ControllerBase {
+ [HttpPut("/_matrix/client/{_}/rooms/{roomId}/send/m.room.message/{txnId}")]
+ public async Task Proxy([FromBody] JsonObject request, [FromRoute] string roomId, [FromRoute] string txnId, string _) {
+ var hs = await hsProvider.GetHomeserver();
+
+ var msg = request.Deserialize<RoomMessageEventContent>();
+ if (msg is not null && msg.Body.StartsWith("mxae!")) {
+#pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed
+ handleMxaeCommand(hs, roomId, msg);
+#pragma warning restore CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed
+ await Response.WriteAsJsonAsync(new EventIdResponse() {
+ EventId = "$" + string.Join("", Random.Shared.GetItems("abcdefghijklmnopqrstuvwxyzABCDEFGHIJLKMNOPQRSTUVWXYZ0123456789".ToCharArray(), 100))
+ });
+ await Response.CompleteAsync();
+ }
+ else {
+ try {
+ var resp = await hs.ClientHttpClient.PutAsJsonAsync($"{Request.Path}{Request.QueryString}", request);
+ var loginResp = await resp.Content.ReadAsStringAsync();
+ Response.StatusCode = (int)resp.StatusCode;
+ Response.ContentType = resp.Content.Headers.ContentType?.ToString() ?? "application/json";
+ await Response.StartAsync();
+ await Response.WriteAsync(loginResp);
+ await Response.CompleteAsync();
+ }
+ catch (MatrixException e) {
+ await Response.StartAsync();
+ await Response.WriteAsync(e.GetAsJson());
+ await Response.CompleteAsync();
+ }
+ }
+ }
+
+ private async Task handleMxaeCommand(AuthenticatedHomeserverGeneric hs, string roomId, RoomMessageEventContent msg) {
+ var syncState = SyncController._syncStates.GetValueOrDefault(hs.AccessToken);
+ if (syncState is null) return;
+ syncState.SendEphemeralTimelineEventInRoom(roomId, new() {
+ Sender = "@mxae:" + Request.Host.Value,
+ Type = "m.room.message",
+ TypedContent = MessageFormatter.FormatSuccess("Thinking..."),
+ OriginServerTs = (ulong)new DateTimeOffset(DateTime.UtcNow.ToUniversalTime()).ToUnixTimeMilliseconds(),
+ Unsigned = new() {
+ Age = 1
+ },
+ RoomId = roomId,
+ EventId = "$" + string.Join("", Random.Shared.GetItems("abcdefghijklmnopqrstuvwxyzABCDEFGHIJLKMNOPQRSTUVWXYZ0123456789".ToCharArray(), 100))
+ });
+ }
+}
\ No newline at end of file
diff --git a/MxApiExtensions/Controllers/Client/SyncController.cs b/MxApiExtensions/Controllers/Client/SyncController.cs
new file mode 100644
index 0000000..2944c3b
--- /dev/null
+++ b/MxApiExtensions/Controllers/Client/SyncController.cs
@@ -0,0 +1,243 @@
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using System.Web;
+using ArcaneLibs;
+using LibMatrix;
+using LibMatrix.EventTypes.Spec.State;
+using LibMatrix.Helpers;
+using LibMatrix.Homeservers;
+using LibMatrix.Responses;
+using LibMatrix.RoomTypes;
+using Microsoft.AspNetCore.Mvc;
+using MxApiExtensions.Classes;
+using MxApiExtensions.Classes.LibMatrix;
+using MxApiExtensions.Extensions;
+using MxApiExtensions.Services;
+
+namespace MxApiExtensions.Controllers;
+
+[ApiController]
+[Route("/")]
+public class SyncController(ILogger<SyncController> logger, MxApiExtensionsConfiguration config, AuthenticationService auth, AuthenticatedHomeserverProviderService hsProvider)
+ : ControllerBase {
+ public static readonly ConcurrentDictionary<string, SyncState> _syncStates = new();
+
+ private static SemaphoreSlim _semaphoreSlim = new(1, 1);
+ private Stopwatch _syncElapsed = Stopwatch.StartNew();
+
+ [HttpGet("/_matrix/client/{_}/sync")]
+ public async Task Sync(string _, [FromQuery] string? since, [FromQuery] int timeout = 1000) {
+ Task? preloadTask = null;
+ AuthenticatedHomeserverGeneric? hs = null;
+ try {
+ hs = await hsProvider.GetHomeserver();
+ }
+ catch (Exception e) {
+ Console.WriteLine(e);
+ }
+
+ var qs = HttpUtility.ParseQueryString(Request.QueryString.Value!);
+ qs.Remove("access_token");
+ if (since == "null") qs.Remove("since");
+
+ if (!config.FastInitialSync.Enabled) {
+ logger.LogInformation("Starting sync for {} on {} ({})", hs.WhoAmI.UserId, hs.ServerName, hs.AccessToken);
+ var result = await hs.ClientHttpClient.GetAsync($"{Request.Path}?{qs}");
+ await Response.WriteHttpResponse(result);
+ return;
+ }
+
+ await _semaphoreSlim.WaitAsync();
+ var syncState = _syncStates.GetOrAdd($"{hs.WhoAmI.UserId}/{hs.WhoAmI.DeviceId}/{hs.ServerName}:{hs.AccessToken}", _ => {
+ logger.LogInformation("Started tracking sync state for {} on {} ({})", hs.WhoAmI.UserId, hs.ServerName, hs.AccessToken);
+ var ss = new SyncState {
+ IsInitialSync = string.IsNullOrWhiteSpace(since),
+ Homeserver = hs
+ };
+ if (ss.IsInitialSync) {
+ preloadTask = EnqueuePreloadData(ss);
+ }
+
+ logger.LogInformation("Starting sync for {} on {} ({})", hs.WhoAmI.UserId, hs.ServerName, hs.AccessToken);
+
+ ss.NextSyncResponseStartedAt = DateTime.Now;
+ ss.NextSyncResponse = Task.Delay(15_000);
+ ss.NextSyncResponse.ContinueWith(async x => {
+ logger.LogInformation("Sync for {} on {} ({}) starting", hs.WhoAmI.UserId, hs.ServerName, hs.AccessToken);
+ ss.NextSyncResponse = hs.ClientHttpClient.GetAsync($"/_matrix/client/v3/sync?{qs}");
+ (ss.NextSyncResponse as Task<HttpResponseMessage>).ContinueWith(async x => EnqueueSyncResponse(ss, await x));
+ });
+ return ss;
+ });
+ _semaphoreSlim.Release();
+
+ if (syncState.SyncQueue.Count > 0) {
+ logger.LogInformation("Sync for {} on {} ({}) has {} queued results", hs.WhoAmI.UserId, hs.ServerName, hs.AccessToken, syncState.SyncQueue.Count);
+ syncState.SyncQueue.TryDequeue(out var result);
+ while (result is null)
+ syncState.SyncQueue.TryDequeue(out result);
+ Response.StatusCode = StatusCodes.Status200OK;
+ Response.ContentType = "application/json";
+ await Response.StartAsync();
+ result.NextBatch ??= since ?? syncState.NextBatch;
+ await JsonSerializer.SerializeAsync(Response.Body, result, new JsonSerializerOptions {
+ WriteIndented = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ });
+ await Response.CompleteAsync();
+ return;
+ }
+
+ var newTimeout = Math.Clamp(timeout, 0, syncState.IsInitialSync ? syncState.SyncQueue.Count >= 2 ? 0 : 250 : timeout);
+ logger.LogInformation("Sync for {} on {} ({}) is still running, waiting for {}ms, {} elapsed", hs.WhoAmI.UserId, hs.ServerName, hs.AccessToken, newTimeout,
+ DateTime.Now.Subtract(syncState.NextSyncResponseStartedAt));
+
+ try {
+ if (syncState.NextSyncResponse is not null)
+ await syncState.NextSyncResponse.WaitAsync(TimeSpan.FromMilliseconds(newTimeout));
+ else {
+ syncState.NextSyncResponse = hs.ClientHttpClient.GetAsync($"/_matrix/client/v3/sync?{qs}");
+ (syncState.NextSyncResponse as Task<HttpResponseMessage>).ContinueWith(async x => EnqueueSyncResponse(syncState, await x));
+ // await Task.Delay(250);
+ }
+ }
+ catch (TimeoutException) { }
+
+ // if (_syncElapsed.ElapsedMilliseconds > timeout)
+ if(syncState.NextSyncResponse?.IsCompleted == false)
+ syncState.SendStatusMessage(
+ $"M={Util.BytesToString(Process.GetCurrentProcess().WorkingSet64)} TE={DateTime.Now.Subtract(syncState.NextSyncResponseStartedAt)} S={syncState.NextSyncResponse?.Status} QL={syncState.SyncQueue.Count}");
+ Response.StatusCode = StatusCodes.Status200OK;
+ Response.ContentType = "application/json";
+ await Response.StartAsync();
+ var response = syncState.SyncQueue.FirstOrDefault();
+ if (response is null)
+ response = new();
+ response.NextBatch ??= since ?? syncState.NextBatch;
+ await JsonSerializer.SerializeAsync(Response.Body, response, new JsonSerializerOptions {
+ WriteIndented = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ });
+ await Response.CompleteAsync();
+
+ Response.Body.Close();
+ if (preloadTask is not null)
+ await preloadTask;
+ }
+
+ private async Task EnqueuePreloadData(SyncState syncState) {
+ var rooms = await syncState.Homeserver.GetJoinedRooms();
+ var dm_rooms = (await syncState.Homeserver.GetAccountDataAsync<Dictionary<string, List<string>>>("m.direct")).Aggregate(new List<string>(), (list, entry) => {
+ list.AddRange(entry.Value);
+ return list;
+ });
+
+ var ownHs = syncState.Homeserver.WhoAmI.UserId.Split(':')[1];
+ rooms = rooms.OrderBy(x => {
+ if (dm_rooms.Contains(x.RoomId)) return -1;
+ var parts = x.RoomId.Split(':');
+ if (parts[1] == ownHs) return 200;
+ if (HomeserverWeightEstimation.EstimatedSize.ContainsKey(parts[1])) return HomeserverWeightEstimation.EstimatedSize[parts[1]] + parts[0].Length;
+ return 5000;
+ }).ToList();
+ var roomDataTasks = rooms.Select(room => EnqueueRoomData(syncState, room)).ToList();
+ logger.LogInformation("Preloading data for {} rooms on {} ({})", roomDataTasks.Count, syncState.Homeserver.ServerName, syncState.Homeserver.AccessToken);
+
+ await Task.WhenAll(roomDataTasks);
+ }
+
+ private SemaphoreSlim _roomDataSemaphore = new(32, 32);
+
+ private async Task EnqueueRoomData(SyncState syncState, GenericRoom room) {
+ await _roomDataSemaphore.WaitAsync();
+ var roomState = room.GetFullStateAsync();
+ var timeline = await room.GetMessagesAsync(limit: 100, dir: "b");
+ timeline.Chunk.Reverse();
+ var SyncResponse = new SyncResponse {
+ Rooms = new() {
+ Join = new() {
+ {
+ room.RoomId,
+ new SyncResponse.RoomsDataStructure.JoinedRoomDataStructure {
+ AccountData = new() {
+ Events = new()
+ },
+ Ephemeral = new() {
+ Events = new()
+ },
+ State = new() {
+ Events = timeline.State
+ },
+ UnreadNotifications = new() {
+ HighlightCount = 0,
+ NotificationCount = 0
+ },
+ Timeline = new() {
+ Events = timeline.Chunk,
+ Limited = false,
+ PrevBatch = timeline.Start
+ },
+ Summary = new() {
+ Heroes = new(),
+ InvitedMemberCount = 0,
+ JoinedMemberCount = 1
+ }
+ }
+ }
+ }
+ },
+ Presence = new() {
+ Events = new() {
+ await GetStatusMessage(syncState, $"{DateTime.Now.Subtract(syncState.NextSyncResponseStartedAt)} {syncState.NextSyncResponse.Status} {room.RoomId}")
+ }
+ },
+ NextBatch = ""
+ };
+
+ await foreach (var stateEvent in roomState) {
+ SyncResponse.Rooms.Join[room.RoomId].State.Events.Add(stateEvent);
+ }
+
+ var joinRoom = SyncResponse.Rooms.Join[room.RoomId];
+ joinRoom.Summary.Heroes.AddRange(joinRoom.State.Events
+ .Where(x =>
+ x.Type == "m.room.member"
+ && x.StateKey != syncState.Homeserver.WhoAmI.UserId
+ && (x.TypedContent as RoomMemberEventContent).Membership == "join"
+ )
+ .Select(x => x.StateKey));
+ joinRoom.Summary.JoinedMemberCount = joinRoom.Summary.Heroes.Count;
+
+ syncState.SyncQueue.Enqueue(SyncResponse);
+ _roomDataSemaphore.Release();
+ }
+
+ private async Task<StateEventResponse> GetStatusMessage(SyncState syncState, string message) {
+ return new StateEventResponse {
+ TypedContent = new PresenceEventContent {
+ DisplayName = "MxApiExtensions",
+ Presence = "online",
+ StatusMessage = message,
+ // AvatarUrl = (await syncState.Homeserver.GetProfile(syncState.Homeserver.WhoAmI.UserId)).AvatarUrl
+ AvatarUrl = ""
+ },
+ Type = "m.presence",
+ StateKey = syncState.Homeserver.WhoAmI.UserId,
+ Sender = syncState.Homeserver.WhoAmI.UserId,
+ EventId = Guid.NewGuid().ToString(),
+ OriginServerTs = 0
+ };
+ }
+
+ private async Task EnqueueSyncResponse(SyncState ss, HttpResponseMessage task) {
+ var sr = await task.Content.ReadFromJsonAsync<JsonObject>();
+ if (sr.ContainsKey("error")) throw sr.Deserialize<MatrixException>()!;
+ ss.NextBatch = sr["next_batch"].GetValue<string>();
+ ss.IsInitialSync = false;
+ ss.SyncQueue.Enqueue(sr.Deserialize<SyncResponse>());
+ ss.NextSyncResponse = null;
+ }
+}
\ No newline at end of file
|