From 2f8a17fab9b13cbeb93f3d7b07b0bb51d17aa8b2 Mon Sep 17 00:00:00 2001 From: Rory& Date: Mon, 16 Jun 2025 06:43:44 +0200 Subject: Room builder, support managing room dir --- ArcaneLibs | 2 +- .../Common/MjolnirShortcodeEventContent.cs | 2 +- .../RoomInfo/RoomHistoryVisibilityEventContent.cs | 6 + .../State/RoomInfo/RoomPolicyServerEventContent.cs | 11 ++ LibMatrix/Extensions/MatrixHttpClient.Single.cs | 4 +- LibMatrix/Helpers/RoomBuilder.cs | 173 +++++++++++++++++++++ .../Homeservers/AuthenticatedHomeserverGeneric.cs | 37 +++++ LibMatrix/Responses/CreateRoomRequest.cs | 2 + LibMatrix/StateEvent.cs | 11 +- 9 files changed, 243 insertions(+), 5 deletions(-) create mode 100644 LibMatrix.EventTypes/Spec/State/RoomInfo/RoomPolicyServerEventContent.cs create mode 100644 LibMatrix/Helpers/RoomBuilder.cs diff --git a/ArcaneLibs b/ArcaneLibs index 58a531f..3fe5c48 160000 --- a/ArcaneLibs +++ b/ArcaneLibs @@ -1 +1 @@ -Subproject commit 58a531f72aaae7d44420fcaa7feb5f494c069971 +Subproject commit 3fe5c483d7ce6d00a9bb7389f63858959c77dff1 diff --git a/LibMatrix.EventTypes/Common/MjolnirShortcodeEventContent.cs b/LibMatrix.EventTypes/Common/MjolnirShortcodeEventContent.cs index a31cbbb..a1ebd79 100644 --- a/LibMatrix.EventTypes/Common/MjolnirShortcodeEventContent.cs +++ b/LibMatrix.EventTypes/Common/MjolnirShortcodeEventContent.cs @@ -3,7 +3,7 @@ using System.Text.Json.Serialization; namespace LibMatrix.EventTypes.Common; [MatrixEvent(EventName = EventId)] -public class MjolnirShortcodeEventContent : TimelineEventContent { +public class MjolnirShortcodeEventContent : EventContent { public const string EventId = "org.matrix.mjolnir.shortcode"; [JsonPropertyName("shortcode")] diff --git a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomHistoryVisibilityEventContent.cs b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomHistoryVisibilityEventContent.cs index 16cfcb0..8edf4a7 100644 --- a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomHistoryVisibilityEventContent.cs +++ b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomHistoryVisibilityEventContent.cs @@ -8,4 +8,10 @@ public class RoomHistoryVisibilityEventContent : EventContent { [JsonPropertyName("history_visibility")] public required string HistoryVisibility { get; set; } + + public static class HistoryVisibilityTypes { + public const string WorldReadable = "world_readable"; + public const string Invited = "invited"; + public const string Shared = "shared"; + } } \ No newline at end of file diff --git a/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomPolicyServerEventContent.cs b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomPolicyServerEventContent.cs new file mode 100644 index 0000000..80e254f --- /dev/null +++ b/LibMatrix.EventTypes/Spec/State/RoomInfo/RoomPolicyServerEventContent.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace LibMatrix.EventTypes.Spec.State.RoomInfo; + +[MatrixEvent(EventName = EventId)] +public class RoomPolicyServerEventContent : EventContent { + public const string EventId = "org.matrix.msc4284.policy"; + + [JsonPropertyName("via")] + public string? Via { get; set; } +} \ No newline at end of file diff --git a/LibMatrix/Extensions/MatrixHttpClient.Single.cs b/LibMatrix/Extensions/MatrixHttpClient.Single.cs index e174f5d..671566f 100644 --- a/LibMatrix/Extensions/MatrixHttpClient.Single.cs +++ b/LibMatrix/Extensions/MatrixHttpClient.Single.cs @@ -286,9 +286,9 @@ public class MatrixHttpClient { return await SendAsync(request, cancellationToken); } - public async Task DeleteAsync(string url) { + public async Task DeleteAsync(string url) { var request = new HttpRequestMessage(HttpMethod.Delete, url); - await SendAsync(request); + return await SendAsync(request); } public async Task DeleteAsJsonAsync(string url, T payload) { diff --git a/LibMatrix/Helpers/RoomBuilder.cs b/LibMatrix/Helpers/RoomBuilder.cs new file mode 100644 index 0000000..bef7568 --- /dev/null +++ b/LibMatrix/Helpers/RoomBuilder.cs @@ -0,0 +1,173 @@ +using System.Runtime.Intrinsics.X86; +using LibMatrix.EventTypes.Spec.State.RoomInfo; +using LibMatrix.Homeservers; +using LibMatrix.Responses; +using LibMatrix.RoomTypes; + +namespace LibMatrix.Helpers; + +public class RoomBuilder { + public string? Type { get; set; } + public string Version { get; set; } = "11"; + public RoomNameEventContent Name { get; set; } = new(); + public RoomTopicEventContent Topic { get; set; } = new(); + public RoomAvatarEventContent Avatar { get; set; } = new(); + public RoomCanonicalAliasEventContent CanonicalAlias { get; set; } = new(); + public string AliasLocalPart { get; set; } = string.Empty; + public bool IsFederatable { get; set; } = true; + public long OwnPowerLevel { get; set; } = MatrixConstants.MaxSafeJsonInteger; + + public RoomJoinRulesEventContent JoinRules { get; set; } = new() { + JoinRule = RoomJoinRulesEventContent.JoinRules.Public + }; + + public RoomHistoryVisibilityEventContent HistoryVisibility { get; set; } = new() { + HistoryVisibility = RoomHistoryVisibilityEventContent.HistoryVisibilityTypes.Shared + }; + + /// + /// State events to be sent *before* room access is configured. Keep this small! + /// + public List ImportantState { get; set; } = []; + + /// + /// State events to be sent *after* room access is configured, but before invites are sent. + /// + public List InitialState { get; set; } = []; + + /// + /// Users to invite, with optional reason + /// + public Dictionary Invites { get; set; } = new(); + + public RoomPowerLevelEventContent PowerLevels { get; init; } = new() { + EventsDefault = 0, + UsersDefault = 0, + Kick = 50, + Invite = 50, + Ban = 50, + Redact = 50, + StateDefault = 50, + NotificationsPl = new() { + Room = 50 + }, + Users = [], + Events = new Dictionary { + { RoomAvatarEventContent.EventId, 50 }, + { RoomCanonicalAliasEventContent.EventId, 50 }, + { RoomEncryptionEventContent.EventId, 100 }, + { RoomHistoryVisibilityEventContent.EventId, 100 }, + { RoomNameEventContent.EventId, 50 }, + { RoomPowerLevelEventContent.EventId, 100 }, + { RoomServerAclEventContent.EventId, 100 }, + { RoomTombstoneEventContent.EventId, 100 }, + { RoomPolicyServerEventContent.EventId, 100 } + } + }; + + public async Task Create(AuthenticatedHomeserverGeneric homeserver) { + var crq = new CreateRoomRequest() { + PowerLevelContentOverride = new() { + EventsDefault = 1000000, + UsersDefault = 1000000, + Kick = 1000000, + Invite = 1000000, + Ban = 1000000, + Redact = 1000000, + StateDefault = 1000000, + NotificationsPl = new() { + Room = 1000000 + }, + Users = new Dictionary() { + { homeserver.WhoAmI.UserId, MatrixConstants.MaxSafeJsonInteger } + }, + Events = new Dictionary { + { RoomAvatarEventContent.EventId, 1000000 }, + { RoomCanonicalAliasEventContent.EventId, 1000000 }, + { RoomEncryptionEventContent.EventId, 1000000 }, + { RoomHistoryVisibilityEventContent.EventId, 1000000 }, + { RoomNameEventContent.EventId, 1000000 }, + { RoomPowerLevelEventContent.EventId, 1000000 }, + { RoomServerAclEventContent.EventId, 1000000 }, + { RoomTombstoneEventContent.EventId, 1000000 }, + { RoomPolicyServerEventContent.EventId, 1000000 } + } + }, + Visibility = "private", + RoomVersion = Version + }; + + if (!string.IsNullOrWhiteSpace(Type)) + crq.CreationContent.Add("type", Type); + + if (!IsFederatable) + crq.CreationContent.Add("m.federate", false); + + var room = await homeserver.CreateRoom(crq); + + await SetBasicRoomInfoAsync(room); + await SetStatesAsync(room, ImportantState); + await SetAccessAsync(room); + await SetStatesAsync(room, InitialState); + await SendInvites(room); + + return room; + } + + private async Task SendInvites(GenericRoom room) { + if (Invites.Count == 0) return; + + var inviteTasks = Invites.Select(async kvp => { + try { + await room.InviteUserAsync(kvp.Key, kvp.Value); + } + catch (MatrixException e) { + Console.Error.WriteLine("Failed to invite {0} to {1}: {2}", kvp.Key, room.RoomId, e.Message); + } + }); + + await Task.WhenAll(inviteTasks); + } + + private async Task SetStatesAsync(GenericRoom room, List state) { + foreach (var ev in state) { + await (string.IsNullOrWhiteSpace(ev.StateKey) + ? room.SendStateEventAsync(ev.Type, ev.RawContent) + : room.SendStateEventAsync(ev.Type, ev.StateKey, ev.RawContent)); + } + } + + private async Task SetBasicRoomInfoAsync(GenericRoom room) { + if (!string.IsNullOrWhiteSpace(Name.Name)) + await room.SendStateEventAsync(RoomNameEventContent.EventId, Name); + + if (!string.IsNullOrWhiteSpace(Topic.Topic)) + await room.SendStateEventAsync(RoomTopicEventContent.EventId, Topic); + + if (!string.IsNullOrWhiteSpace(Avatar.Url)) + await room.SendStateEventAsync(RoomAvatarEventContent.EventId, Avatar); + + if (!string.IsNullOrWhiteSpace(AliasLocalPart)) + CanonicalAlias.Alias = $"#{AliasLocalPart}:{room.Homeserver.ServerName}"; + + if (!string.IsNullOrWhiteSpace(CanonicalAlias.Alias)) { + await room.Homeserver.SetRoomAliasAsync(CanonicalAlias.Alias!, room.RoomId); + await room.SendStateEventAsync(RoomCanonicalAliasEventContent.EventId, CanonicalAlias); + } + } + + private async Task SetAccessAsync(GenericRoom room) { + PowerLevels.Users![room.Homeserver.WhoAmI.UserId] = OwnPowerLevel; + await room.SendStateEventAsync(RoomPowerLevelEventContent.EventId, PowerLevels); + + if (!string.IsNullOrWhiteSpace(HistoryVisibility.HistoryVisibility)) + await room.SendStateEventAsync(RoomHistoryVisibilityEventContent.EventId, HistoryVisibility); + + if (!string.IsNullOrWhiteSpace(JoinRules.JoinRuleValue)) + await room.SendStateEventAsync(RoomJoinRulesEventContent.EventId, JoinRules); + } +} + +public class MatrixConstants { + public const long MaxSafeJsonInteger = 9007199254740991L; // 2^53 - 1 +} \ No newline at end of file diff --git a/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs b/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs index 55899de..5fd3311 100644 --- a/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs +++ b/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs @@ -578,4 +578,41 @@ public class AuthenticatedHomeserverGeneric : RemoteHomeserver { [JsonPropertyName("capabilities")] public Dictionary? Capabilities { get; set; } } + +#region Room Directory/aliases + + public async Task SetRoomAliasAsync(string roomAlias, string roomId) { + var resp = await ClientHttpClient.PutAsJsonAsync($"/_matrix/client/v3/directory/room/{HttpUtility.UrlEncode(roomAlias)}", new RoomIdResponse() { + RoomId = roomId + }); + if (!resp.IsSuccessStatusCode) { + Console.WriteLine($"Failed to set room alias: {await resp.Content.ReadAsStringAsync()}"); + throw new InvalidDataException($"Failed to set room alias: {await resp.Content.ReadAsStringAsync()}"); + } + } + + public async Task DeleteRoomAliasAsync(string roomAlias) { + var resp = await ClientHttpClient.DeleteAsync("/_matrix/client/v3/directory/room/" + HttpUtility.UrlEncode(roomAlias)); + if (!resp.IsSuccessStatusCode) { + Console.WriteLine($"Failed to set room alias: {await resp.Content.ReadAsStringAsync()}"); + throw new InvalidDataException($"Failed to set room alias: {await resp.Content.ReadAsStringAsync()}"); + } + } + + public async Task GetLocalRoomAliasesAsync(string roomId) { + var resp = await ClientHttpClient.GetAsync($"/_matrix/client/v3/rooms/{HttpUtility.UrlEncode(roomId)}/aliases"); + if (!resp.IsSuccessStatusCode) { + Console.WriteLine($"Failed to get room aliases: {await resp.Content.ReadAsStringAsync()}"); + throw new InvalidDataException($"Failed to get room aliases: {await resp.Content.ReadAsStringAsync()}"); + } + + return await resp.Content.ReadFromJsonAsync() ?? throw new Exception("Failed to get room aliases?"); + } + + public class RoomAliasesResponse { + [JsonPropertyName("aliases")] + public required List Aliases { get; set; } + } + +#endregion } \ No newline at end of file diff --git a/LibMatrix/Responses/CreateRoomRequest.cs b/LibMatrix/Responses/CreateRoomRequest.cs index d9a6acd..6933622 100644 --- a/LibMatrix/Responses/CreateRoomRequest.cs +++ b/LibMatrix/Responses/CreateRoomRequest.cs @@ -47,6 +47,8 @@ public class CreateRoomRequest { [JsonPropertyName("invite")] public List? Invite { get; set; } + public string? RoomVersion { get; set; } + /// /// For use only when you can't use the CreationContent property /// diff --git a/LibMatrix/StateEvent.cs b/LibMatrix/StateEvent.cs index a1ca9dc..af25805 100644 --- a/LibMatrix/StateEvent.cs +++ b/LibMatrix/StateEvent.cs @@ -121,7 +121,16 @@ public class StateEvent { public string InternalContentTypeName => TypedContent?.GetType().Name ?? "null"; public static bool TypeKeyPairMatches(StateEventResponse x, StateEventResponse y) => x.Type == y.Type && x.StateKey == y.StateKey; - public static bool Equals(StateEventResponse x, StateEventResponse y) => x.Type == y.Type && x.StateKey == y.StateKey && JsonNode.DeepEquals(x.RawContent, y.RawContent); + public static bool Equals(StateEventResponse x, StateEventResponse y) => x.Type == y.Type && x.StateKey == y.StateKey && x.EventId == y.EventId; + + /// + /// Compares two state events for deep equality, including type, state key, and raw content. + /// If you trust the server, use Equals instead, as that compares by event ID instead of raw content. + /// + /// + /// + /// + public static bool DeepEquals(StateEventResponse x, StateEventResponse y) => x.Type == y.Type && x.StateKey == y.StateKey && JsonNode.DeepEquals(x.RawContent, y.RawContent); } public class StateEventResponse : StateEvent { -- cgit 1.5.1