using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Web; using ArcaneLibs.Extensions; using LibMatrix.EventTypes.Spec.State.RoomInfo; using LibMatrix.Filters; using LibMatrix.Helpers; using LibMatrix.Homeservers.Extensions.NamedCaches; using LibMatrix.Responses; using LibMatrix.RoomTypes; using LibMatrix.Services; using LibMatrix.Utilities; namespace LibMatrix.Homeservers; public class AuthenticatedHomeserverGeneric : RemoteHomeserver { public AuthenticatedHomeserverGeneric(string serverName, HomeserverResolverService.WellKnownUris wellKnownUris, string? proxy, string accessToken) : base(serverName, wellKnownUris, proxy) { AccessToken = accessToken; ClientHttpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); NamedCaches = new HsNamedCaches(this); } public async Task Initialise() { WhoAmI = await ClientHttpClient.GetFromJsonAsync<WhoAmIResponse>("/_matrix/client/v3/account/whoami"); } private WhoAmIResponse? _whoAmI; public WhoAmIResponse WhoAmI { get => _whoAmI ?? throw new Exception("Initialise was not called or awaited, WhoAmI is null!"); private set => _whoAmI = value; } public string UserId => WhoAmI.UserId; public string UserLocalpart => UserId.Split(":")[0][1..]; public string ServerName => UserId.Split(":", 2)[1]; public string BaseUrl => ClientHttpClient.BaseAddress!.ToString().TrimEnd('/'); [JsonIgnore] public string AccessToken { get; set; } public HsNamedCaches NamedCaches { get; set; } 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); } public virtual async Task<List<GenericRoom>> GetJoinedRooms() { var roomQuery = await ClientHttpClient.GetAsync("/_matrix/client/v3/joined_rooms"); var roomsJson = await roomQuery.Content.ReadFromJsonAsync<JsonElement>(); var rooms = roomsJson.GetProperty("joined_rooms").EnumerateArray().Select(room => GetRoom(room.GetString()!)).ToList(); return rooms; } public virtual async Task<string> UploadFile(string fileName, IEnumerable<byte> data, string contentType = "application/octet-stream") { return await UploadFile(fileName, data.ToArray(), contentType); } public virtual async Task<string> UploadFile(string fileName, byte[] data, string contentType = "application/octet-stream") { await using var ms = new MemoryStream(data); return await UploadFile(fileName, ms, contentType); } public virtual async Task<string> UploadFile(string fileName, Stream fileStream, string contentType = "application/octet-stream") { var req = new HttpRequestMessage(HttpMethod.Post, $"/_matrix/media/v3/upload?filename={fileName}"); req.Content = new StreamContent(fileStream); req.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType); var res = await ClientHttpClient.SendAsync(req); if (!res.IsSuccessStatusCode) { Console.WriteLine($"Failed to upload file: {await res.Content.ReadAsStringAsync()}"); throw new InvalidDataException($"Failed to upload file: {await res.Content.ReadAsStringAsync()}"); } var resJson = await res.Content.ReadFromJsonAsync<JsonElement>(); return resJson.GetProperty("content_uri").GetString()!; } public virtual async Task<GenericRoom> CreateRoom(CreateRoomRequest creationEvent, bool returnExistingIfAliasExists = false, bool joinIfAliasExists = false, bool inviteIfAliasExists = false) { if (returnExistingIfAliasExists) { var aliasRes = await ResolveRoomAliasAsync($"#{creationEvent.RoomAliasName}:{ServerName}"); if (aliasRes?.RoomId != null) { var existingRoom = GetRoom(aliasRes.RoomId); if (joinIfAliasExists) await existingRoom.JoinAsync(); if (inviteIfAliasExists) await existingRoom.InviteUsersAsync(creationEvent.Invite ?? new List<string>()); return existingRoom; } } creationEvent.CreationContent["creator"] = WhoAmI.UserId; var res = await ClientHttpClient.PostAsJsonAsync("/_matrix/client/v3/createRoom", creationEvent, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }); if (!res.IsSuccessStatusCode) { Console.WriteLine($"Failed to create room: {await res.Content.ReadAsStringAsync()}"); throw new InvalidDataException($"Failed to create room: {await res.Content.ReadAsStringAsync()}"); } var room = GetRoom((await res.Content.ReadFromJsonAsync<JsonObject>())!["room_id"]!.ToString()); if (creationEvent.Invite is not null) await room.InviteUsersAsync(creationEvent.Invite ?? new List<string>()); return room; } public virtual async Task Logout() { var res = await ClientHttpClient.PostAsync("/_matrix/client/v3/logout", null); if (!res.IsSuccessStatusCode) { Console.WriteLine($"Failed to logout: {await res.Content.ReadAsStringAsync()}"); throw new InvalidDataException($"Failed to logout: {await res.Content.ReadAsStringAsync()}"); } } #region Utility Functions public virtual async IAsyncEnumerable<GenericRoom> GetJoinedRoomsByType(string type, int? semaphoreCount = null) { var rooms = await GetJoinedRooms(); SemaphoreSlim? semaphoreSlim = semaphoreCount is null ? null : new(semaphoreCount.Value, semaphoreCount.Value); var tasks = rooms.Select(async room => { while (true) { if (semaphoreSlim is not null) await semaphoreSlim.WaitAsync(); try { var roomType = await room.GetRoomType(); if (semaphoreSlim is not null) semaphoreSlim.Release(); if (roomType == type) return room; return null; } catch (MatrixException e) { throw; } catch (Exception e) { Console.WriteLine($"Failed to get room type for {room.RoomId}: {e.Message}"); await Task.Delay(1000); } } }).ToAsyncEnumerable(); await foreach (var result in tasks) if (result is not null) yield return result; } #endregion #region Account Data public virtual async Task<T> GetAccountDataAsync<T>(string key) => // var res = await _httpClient.GetAsync($"/_matrix/client/v3/user/{UserId}/account_data/{key}"); // if (!res.IsSuccessStatusCode) { // Console.WriteLine($"Failed to get account data: {await res.Content.ReadAsStringAsync()}"); // throw new InvalidDataException($"Failed to get account data: {await res.Content.ReadAsStringAsync()}"); // } // // return await res.Content.ReadFromJsonAsync<T>(); await ClientHttpClient.GetFromJsonAsync<T>($"/_matrix/client/v3/user/{WhoAmI.UserId}/account_data/{key}"); public virtual async Task<T?> GetAccountDataOrNullAsync<T>(string key) { try { return await GetAccountDataAsync<T>(key); } catch (Exception e) { return default; } } public virtual async Task SetAccountDataAsync(string key, object data) { var res = await ClientHttpClient.PutAsJsonAsync($"/_matrix/client/v3/user/{WhoAmI.UserId}/account_data/{key}", data); if (!res.IsSuccessStatusCode) { Console.WriteLine($"Failed to set account data: {await res.Content.ReadAsStringAsync()}"); throw new InvalidDataException($"Failed to set account data: {await res.Content.ReadAsStringAsync()}"); } } #endregion 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})"); var oldProfile = await GetProfileAsync(WhoAmI.UserId!); Dictionary<string, RoomMemberEventContent> expectedRoomProfiles = new(); var syncHelper = new SyncHelper(this) { Filter = new SyncFilter { AccountData = new SyncFilter.EventFilter() { Types = new List<string> { "m.room.member" } } }, Timeout = 250 }; var targetSyncCount = 0; if (preserveCustomRoomProfile) { var rooms = await GetJoinedRooms(); var roomProfiles = rooms.Select(GetOwnRoomProfileWithIdAsync).ToAsyncEnumerable(); targetSyncCount = rooms.Count; await foreach (var (roomId, currentRoomProfile) in roomProfiles) try { // var currentRoomProfile = await room.GetStateAsync<RoomMemberEventContent>("m.room.member", WhoAmI.UserId!); //build new profiles if (currentRoomProfile.DisplayName == oldProfile.DisplayName) currentRoomProfile.DisplayName = newProfile.DisplayName; if (currentRoomProfile.AvatarUrl == oldProfile.AvatarUrl) currentRoomProfile.AvatarUrl = newProfile.AvatarUrl; currentRoomProfile.Reason = null; expectedRoomProfiles.Add(roomId, currentRoomProfile); } catch (Exception e) { } Console.WriteLine($"Rooms with custom profiles: {string.Join(',', expectedRoomProfiles.Keys)}"); } if (oldProfile.DisplayName != newProfile.DisplayName) await ClientHttpClient.PutAsJsonAsync($"/_matrix/client/v3/profile/{WhoAmI.UserId}/displayname", new { displayname = newProfile.DisplayName }); else Console.WriteLine($"Not updating display name because {oldProfile.DisplayName} == {newProfile.DisplayName}"); if (oldProfile.AvatarUrl != newProfile.AvatarUrl) await ClientHttpClient.PutAsJsonAsync($"/_matrix/client/v3/profile/{WhoAmI.UserId}/avatar_url", new { avatar_url = newProfile.AvatarUrl }); else Console.WriteLine($"Not updating avatar URL because {newProfile.AvatarUrl} == {newProfile.AvatarUrl}"); if (!preserveCustomRoomProfile) return; var syncCount = 0; await foreach (var sync in syncHelper.EnumerateSyncAsync()) { if (sync.Rooms is null) break; List<Task> tasks = new(); foreach (var (roomId, roomData) in sync.Rooms.Join) if (roomData.State is { Events.Count: > 0 }) { var incommingRoomProfile = roomData.State?.Events?.FirstOrDefault(x => x.Type == "m.room.member" && x.StateKey == WhoAmI.UserId)?.TypedContent as RoomMemberEventContent; if (incommingRoomProfile is null) continue; if (!expectedRoomProfiles.ContainsKey(roomId)) continue; var targetRoomProfileOverride = expectedRoomProfiles[roomId]; var room = GetRoom(roomId); if (incommingRoomProfile.DisplayName != targetRoomProfileOverride.DisplayName || incommingRoomProfile.AvatarUrl != targetRoomProfileOverride.AvatarUrl) tasks.Add(room.SendStateEventAsync("m.room.member", WhoAmI.UserId, targetRoomProfileOverride)); } await Task.WhenAll(tasks); await Task.Delay(1000); var differenceFound = false; if (syncCount++ >= targetSyncCount) { var profiles = GetRoomProfilesAsync(); await foreach (var (roomId, profile) in profiles) { if (!expectedRoomProfiles.ContainsKey(roomId)) { Console.WriteLine($"Skipping profile check for {roomId} because its not in override list?"); continue; } var targetRoomProfileOverride = expectedRoomProfiles[roomId]; if (profile.DisplayName != targetRoomProfileOverride.DisplayName || profile.AvatarUrl != targetRoomProfileOverride.AvatarUrl) { differenceFound = true; break; } } if (!differenceFound) return; } } } public async IAsyncEnumerable<KeyValuePair<string, RoomMemberEventContent>> GetRoomProfilesAsync() { var rooms = await GetJoinedRooms(); var results = rooms.Select(GetOwnRoomProfileWithIdAsync).ToAsyncEnumerable(); await foreach (var res in results) yield return res; } public async Task<RoomIdResponse> JoinRoomAsync(string roomId, List<string> homeservers = null, string? reason = null) { var joinUrl = $"/_matrix/client/v3/join/{HttpUtility.UrlEncode(roomId)}"; Console.WriteLine($"Calling {joinUrl} with {homeservers?.Count ?? 0} via's..."); if (homeservers == null || homeservers.Count == 0) homeservers = new List<string> { roomId.Split(':')[1] }; var fullJoinUrl = $"{joinUrl}?server_name=" + string.Join("&server_name=", homeservers); var res = await ClientHttpClient.PostAsJsonAsync(fullJoinUrl, new { reason }); return await res.Content.ReadFromJsonAsync<RoomIdResponse>() ?? throw new Exception("Failed to join room?"); } #region Room Profile Utility private async Task<KeyValuePair<string, RoomMemberEventContent>> GetOwnRoomProfileWithIdAsync(GenericRoom room) => new(room.RoomId, await room.GetStateAsync<RoomMemberEventContent>("m.room.member", WhoAmI.UserId!)); #endregion public async Task SetImpersonate(string mxid) { if (ClientHttpClient.AdditionalQueryParameters.TryGetValue("user_id", out var existingMxid) && existingMxid == mxid && WhoAmI.UserId == mxid) return; ClientHttpClient.AdditionalQueryParameters["user_id"] = mxid; WhoAmI = await ClientHttpClient.GetFromJsonAsync<WhoAmIResponse>("/_matrix/client/v3/account/whoami"); } /// <summary> /// Upload a filter to the homeserver. Substitutes @me with the user's ID. /// </summary> /// <param name="filter"></param> /// <returns></returns> /// <exception cref="Exception"></exception> public async Task<FilterIdResponse> UploadFilterAsync(SyncFilter filter) { List<List<string>?> senderLists = [ filter.AccountData?.Senders, filter.AccountData?.NotSenders, filter.Presence?.Senders, filter.Presence?.NotSenders, filter.Room?.AccountData?.Senders, filter.Room?.AccountData?.NotSenders, filter.Room?.Ephemeral?.Senders, filter.Room?.Ephemeral?.NotSenders, filter.Room?.State?.Senders, filter.Room?.State?.NotSenders, filter.Room?.Timeline?.Senders, filter.Room?.Timeline?.NotSenders ]; foreach (var list in senderLists) if (list is { Count: > 0 } && list.Contains("@me")) { list.Remove("@me"); list.Add(UserId); } var resp = await ClientHttpClient.PostAsJsonAsync("/_matrix/client/v3/user/" + UserId + "/filter", filter); return await resp.Content.ReadFromJsonAsync<FilterIdResponse>() ?? throw new Exception("Failed to upload filter?"); } public async Task<SyncFilter> GetFilterAsync(string filterId) { if (_filterCache.TryGetValue(filterId, out var filter)) return filter; var resp = await ClientHttpClient.GetAsync("/_matrix/client/v3/user/" + UserId + "/filter/" + filterId); return _filterCache[filterId] = await resp.Content.ReadFromJsonAsync<SyncFilter>() ?? throw new Exception("Failed to get filter?"); } public class FilterIdResponse { [JsonPropertyName("filter_id")] public required string FilterId { get; set; } } /// <summary> /// Enumerate all account data per room. /// <b>Warning</b>: This uses /sync! /// </summary> /// <param name="includeGlobal">Include non-room account data</param> /// <returns>Dictionary of room IDs and their account data.</returns> /// <exception cref="Exception"></exception> public async Task<Dictionary<string, EventList?>> EnumerateAccountDataPerRoom(bool includeGlobal = false) { var syncHelper = new SyncHelper(this); syncHelper.FilterId = await NamedCaches.FilterCache.GetOrSetValueAsync(CommonSyncFilters.GetAccountDataWithRooms); var resp = await syncHelper.SyncAsync(); if (resp is null) throw new Exception("Sync failed"); var perRoomAccountData = new Dictionary<string, EventList?>(); if (includeGlobal) perRoomAccountData[""] = resp.AccountData; foreach (var (roomId, room) in resp.Rooms?.Join ?? []) perRoomAccountData[roomId] = room.AccountData; return perRoomAccountData; } /// <summary> /// Enumerate all non-room account data. /// <b>Warning</b>: This uses /sync! /// </summary> /// <returns>All account data.</returns> /// <exception cref="Exception"></exception> public async Task<EventList?> EnumerateAccountData() { var syncHelper = new SyncHelper(this) { FilterId = await NamedCaches.FilterCache.GetOrSetValueAsync(CommonSyncFilters.GetAccountData) }; var resp = await syncHelper.SyncAsync(); return resp?.AccountData ?? throw new Exception("Sync failed"); } private Dictionary<string, string>? _namedFilterCache; private Dictionary<string, SyncFilter> _filterCache = new(); public async Task<JsonObject?> GetCapabilitiesAsync() { var res = await ClientHttpClient.GetAsync("/_matrix/client/v3/capabilities"); if (!res.IsSuccessStatusCode) { Console.WriteLine($"Failed to get capabilities: {await res.Content.ReadAsStringAsync()}"); throw new InvalidDataException($"Failed to get capabilities: {await res.Content.ReadAsStringAsync()}"); } return await res.Content.ReadFromJsonAsync<JsonObject>(); } public class HsNamedCaches { internal HsNamedCaches(AuthenticatedHomeserverGeneric hs) { FileCache = new NamedFileCache(hs); FilterCache = new NamedFilterCache(hs); } public NamedFilterCache FilterCache { get; init; } public NamedFileCache FileCache { get; init; } } #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 bool? _serverSupportsAuthMedia; public async Task<string> GetMediaUrlAsync(MxcUri mxcUri, string? filename = null, int? timeout = null) { if (_serverSupportsAuthMedia == true) return mxcUri.ToDownloadUri(BaseUrl, filename, timeout); if (_serverSupportsAuthMedia == false) return mxcUri.ToLegacyDownloadUri(BaseUrl, filename, timeout); try { // Console.WriteLine($"Trying authenticated media URL: {uri}"); var res = await ClientHttpClient.SendAsync(new() { Method = HttpMethod.Head, RequestUri = (new Uri(mxcUri.ToDownloadUri(BaseUrl, filename, timeout), string.IsNullOrWhiteSpace(BaseUrl) ? UriKind.Relative : UriKind.Absolute)) }); if (res.IsSuccessStatusCode) { _serverSupportsAuthMedia = true; return mxcUri.ToDownloadUri(BaseUrl, filename, timeout); } } catch (MatrixException e) { if (e is not { ErrorCode: "M_UNKNOWN" }) throw; } //fallback to legacy media try { // Console.WriteLine($"Trying legacy media URL: {uri}"); var res = await ClientHttpClient.SendAsync(new() { Method = HttpMethod.Head, RequestUri = new(mxcUri.ToLegacyDownloadUri(BaseUrl, filename, timeout), string.IsNullOrWhiteSpace(BaseUrl) ? UriKind.Relative : UriKind.Absolute) }); if (res.IsSuccessStatusCode) { _serverSupportsAuthMedia = false; return mxcUri.ToLegacyDownloadUri(BaseUrl, filename, timeout); } } catch (MatrixException e) { if (e is not { ErrorCode: "M_UNKNOWN" }) throw; } throw new LibMatrixException() { ErrorCode = LibMatrixException.ErrorCodes.M_UNSUPPORTED, Error = "Failed to get media URL" }; } public async Task<Stream> GetMediaStreamAsync(string mxcUri, string? filename = null, int? timeout = null) { var uri = await GetMediaUrlAsync(mxcUri, filename, timeout); var res = await ClientHttpClient.GetAsync(uri); return await res.Content.ReadAsStreamAsync(); } public async Task<Stream> GetThumbnailStreamAsync(MxcUri mxcUri, int width, int height, string? method = null, int? timeout = null) { var (serverName, mediaId) = 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/v3/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/v3/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 }