using System.Diagnostics.CodeAnalysis; using System.Net.WebSockets; using System.Runtime.InteropServices; using System.Text; using System.Threading.Channels; using ASodium; using Dalamud.Game.Text; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Logging; using Dalamud.Utility; using ExtraChat.Protocol; using ExtraChat.Protocol.Channels; using ExtraChat.Ui; using ExtraChat.Util; using Lumina.Excel.GeneratedSheets; using Channel = ExtraChat.Protocol.Channels.Channel; namespace ExtraChat; internal class Client : IDisposable { private const int IsUpPingNumber = 42069; internal enum State { Disconnected, Connecting, NotAuthenticated, RetrievingChallenge, WaitingForVerification, Verifying, Authenticating, FailedAuthentication, Connected, } private Plugin Plugin { get; } private ClientWebSocket WebSocket { get; set; } internal State Status { get; private set; } = State.Disconnected; private bool _active = true; private uint _number = 1; private bool _wasConnected; private KeyPair KeyPair { get; } private readonly Mutex _waitersMutex = new(); private Dictionary> Waiters { get; set; } = new(); private Channel<(RequestContainer, ChannelWriter>?)> ToSend { get; set; } = System.Threading.Channels.Channel.CreateUnbounded<(RequestContainer, ChannelWriter>?)>(); internal Dictionary Channels { get; } = new(); internal Dictionary InvitedChannels { get; } = new(); internal Dictionary ChannelRanks { get; } = new(); internal Client(Plugin plugin) { this.Plugin = plugin; this.WebSocket = new ClientWebSocket(); this.KeyPair = SodiumKeyExchange.GenerateKeyPair(); this.Plugin.ClientState.Login += this.Login; this.Plugin.ClientState.Logout += this.Logout; if (this.Plugin.ClientState.IsLoggedIn) { this.StartLoop(); } } public void Dispose() { this.Plugin.ClientState.Login -= this.Login; this.Plugin.ClientState.Logout -= this.Logout; this._active = false; this.WebSocket.Dispose(); } private void Login(object? sender, EventArgs e) { this.StartLoop(); } private void Logout(object? sender, EventArgs e) { this.StopLoop(); } internal bool TryGetChannel(Guid id, [MaybeNullWhen(false)] out Channel channel) { return this.Channels.TryGetValue(id, out channel) || this.InvitedChannels.TryGetValue(id, out channel); } internal void StopLoop() { this._active = false; this.WebSocket.Abort(); } internal void StartLoop() { this._active = true; Task.Run(async () => { while (this._active) { try { await this.Loop(); } catch (Exception ex) { PluginLog.LogError(ex, "Error in client loop"); if (this._wasConnected) { this.Plugin.ChatGui.PrintChat(new XivChatEntry { Message = "Disconnected from ExtraChat. Trying to reconnect.", Type = XivChatType.Urgent, }); } } await Task.Delay(TimeSpan.FromSeconds(3)); } // ReSharper disable once FunctionNeverReturns }); } private ChannelReader RegisterWaiter(uint number) { var channel = System.Threading.Channels.Channel.CreateBounded(1); this._waitersMutex.WaitOne(); this.Waiters[number] = channel.Writer; this._waitersMutex.ReleaseMutex(); return channel.Reader; } private async Task QueueMessage(RequestKind request) { var container = new RequestContainer { Number = this._number++, Kind = request, }; await this.ToSend.Writer.WriteAsync((container, null)); } private async Task QueueMessageAndWait(RequestKind request) { var container = new RequestContainer { Number = this._number++, Kind = request, }; var channel = System.Threading.Channels.Channel.CreateBounded>(1); await this.ToSend.Writer.WriteAsync((container, channel.Writer)); var what = await channel.Reader.ReadAsync(); return await what.ReadAsync(); } private byte[] GetPrivateKey() { var key = new byte[this.KeyPair.GetPrivateKeyLength()]; SodiumGuardedHeapAllocation.Sodium_MProtect_ReadOnly(this.KeyPair.GetPrivateKey()); Marshal.Copy(this.KeyPair.GetPrivateKey(), key, 0, this.KeyPair.GetPrivateKeyLength()); SodiumGuardedHeapAllocation.Sodium_MProtect_NoAccess(this.KeyPair.GetPrivateKey()); return key; } internal async Task Connect() { await this.WebSocket.ConnectAsync(new Uri("wss://extrachat.annaclemens.io/"), CancellationToken.None); } internal Task AuthenticateAndList() { return Task.Run(async () => { if (await this.Authenticate()) { this._wasConnected = true; this.Plugin.ChatGui.PrintChat(new XivChatEntry { Message = "Connected to ExtraChat.", Type = XivChatType.Notice, }); await this.ListAll(); } }); } /// /// Gets the challenge to put in the user's Lodestone profile. /// /// challenge or null if LocalPlayer is null /// if the server returns an error or unexpected output internal async Task GetChallenge() { if (this.Plugin.LocalPlayer is not { } player) { return null; } this.Status = State.RetrievingChallenge; var response = await this.QueueMessageAndWait(new RequestKind.Register(new RegisterRequest { Name = player.Name.TextValue, World = (ushort) player.HomeWorld.Id, ChallengeCompleted = false, })); switch (response) { case ResponseKind.Error { Response.Error: var error }: this.Status = State.NotAuthenticated; throw new Exception(error); case ResponseKind.Register { Response: RegisterResponse.Challenge { Text: var challenge } }: this.Status = State.WaitingForVerification; return challenge; default: this.Status = State.NotAuthenticated; throw new Exception("Unexpected response"); } } internal async Task<(Channel, byte[])> Create(string name) { var shared = SodiumSecretBoxXChaCha20Poly1305.GenerateKey(); var nonce = SodiumSecretBoxXChaCha20Poly1305.GenerateNonce(); var ciphertext = SodiumSecretBoxXChaCha20Poly1305.Create(Encoding.UTF8.GetBytes(name), nonce, shared); var encryptedName = nonce.Concat(ciphertext); var response = await this.QueueMessageAndWait(new RequestKind.Create(new CreateRequest { Name = encryptedName, })); var channelInfo = response switch { ResponseKind.Error { Response.Error: var error } => throw new Exception(error), ResponseKind.Create { Response.Channel: var channel } => (channel, shared), _ => throw new Exception("invalid response"), }; this.Plugin.ConfigInfo.RegisterChannel(channelInfo.channel, channelInfo.shared); this.Channels[channelInfo.channel.Id] = channelInfo.channel; this.ChannelRanks[channelInfo.channel.Id] = Rank.Admin; this.Plugin.Commands.ReregisterAll(); this.Plugin.SaveConfig(); return channelInfo; } internal async Task Invite(string name, ushort world, Guid channel) { // Invite requires three steps: // 1. Get the public key of the invitee // 2. Encrypt the shared key with the public key // NOTE: in all cases, the party initiating the key exchange is // considered the CLIENT // 3. Send the invite with the encrypted shared key // 0. Get the channel shared key if (!this.Plugin.ConfigInfo.Channels.TryGetValue(channel, out var channelInfo)) { return null; } // 1. Get the public key of the invitee var response = await this.QueueMessageAndWait(new RequestKind.PublicKey(new PublicKeyRequest { Name = name, World = world, })); var invitee = response switch { ResponseKind.Error { Response.Error: var error } => throw new Exception(error), ResponseKind.PublicKey { Response.PublicKey: var respKey } => respKey, _ => throw new Exception("invalid response"), }; if (invitee == null) { return null; } // 2. Encrypt the shared key with the public key var kx = SodiumKeyExchange.CalculateClientSharedSecret(this.KeyPair.GetPublicKey(), this.GetPrivateKey(), invitee); var encryptedShared = SecretBox.Encrypt(kx.TransferSharedSecret, channelInfo.SharedSecret); // 3. Send the invite with the encrypted shared key response = await this.QueueMessageAndWait(new RequestKind.Invite(new InviteRequest { Channel = channel, Name = name, World = world, EncryptedSecret = encryptedShared, })); return response switch { ResponseKind.Error { Response.Error: var error } => throw new Exception(error), ResponseKind.Invite { Response: var invite } => invite, _ => throw new Exception("Unexpected response"), }; } internal async Task InviteToast(string name, ushort world, Guid channel) { var worldName = WorldUtil.WorldName(world); var channelName = this.Plugin.ConfigInfo.GetName(channel); try { if (await this.Invite(name, world, channel) == null) { this.Plugin.ShowError($"Could not invite {name}{PluginUi.CrossWorld}{worldName} to \"{channelName}\": not logged into ExtraChat"); } else { this.Plugin.ShowInfo($"Invited {name}{PluginUi.CrossWorld}{worldName} to \"{channelName}\""); } } catch (Exception ex) { this.Plugin.ShowError($"Could not invite {name}{PluginUi.CrossWorld}{worldName} to \"{channelName}\": {ex.Message}"); } } /// /// Attempts to register the user after the challenge has been completed. /// /// authentication key or null if LocalPlayer was null or the challenge failed /// if the server returns an error or unexpected output internal async Task Register() { if (this.Plugin.LocalPlayer is not { } player) { return null; } this.Status = State.Verifying; var response = await this.QueueMessageAndWait(new RequestKind.Register(new RegisterRequest { Name = player.Name.TextValue, World = (ushort) player.HomeWorld.Id, ChallengeCompleted = true, })); switch (response) { case ResponseKind.Error { Response.Error: var error }: this.Status = State.WaitingForVerification; throw new Exception(error); case ResponseKind.Register { Response: RegisterResponse.Failure }: this.Status = State.WaitingForVerification; return null; case ResponseKind.Register { Response: RegisterResponse.Success { Key: var key } }: this.Status = State.NotAuthenticated; return key; default: throw new Exception("Unexpected response"); } } internal async Task Authenticate() { if (this.Plugin.ConfigInfo.Key is not { } key) { return false; } this.Status = State.Authenticating; var response = await this.QueueMessageAndWait(new RequestKind.Authenticate(new AuthenticateRequest { Key = key, PublicKey = this.KeyPair.GetPublicKey(), })); var success = response switch { ResponseKind.Error => false, ResponseKind.Authenticate { Response.Error: null } => true, ResponseKind.Authenticate => false, _ => false, }; this.Status = success ? State.Connected : State.FailedAuthentication; return success; } internal async Task SendMessage(Guid channel, byte[] message) { await this.QueueMessage(new RequestKind.Message(new MessageRequest { Channel = channel, Message = message, })); } internal async Task ListAll() { await this.QueueMessage(new RequestKind.List(new ListRequest.All())); } internal async Task ListMembers(Guid channelId) { await this.QueueMessage(new RequestKind.List(new ListRequest.Members(channelId))); } internal async Task Join(Guid channelId) { if (!this.Plugin.ConfigInfo.Channels.TryGetValue(channelId, out var info)) { return; } var response = await this.QueueMessageAndWait(new RequestKind.Join(new JoinRequest { Channel = channelId, })); switch (response) { case ResponseKind.Error { Response.Error: var error }: { this.Plugin.ShowError($"Failed to join \"{info.Name}\": {error}"); break; } case ResponseKind.Join { Response: var resp }: { this.Plugin.ShowInfo($"Joined \"{info.Name}\""); this.InvitedChannels.Remove(channelId); this.Channels[channelId] = resp.Channel; this.ChannelRanks[channelId] = Rank.Member; this.Plugin.ConfigInfo.AddChannelIndex(resp.Channel.Id); this.Plugin.ConfigInfo.UpdateChannel(resp.Channel); this.Plugin.SaveConfig(); this.Plugin.Commands.ReregisterAll(); break; } default: { throw new Exception("Unexpected response"); } } } internal async Task Leave(Guid channelId) { var response = await this.QueueMessageAndWait(new RequestKind.Leave(new LeaveRequest { Channel = channelId, })); if (response is ResponseKind.Leave { Response: { Error: null, Channel: var id } }) { this.ActuallyLeave(id); } } private void ActuallyLeave(Guid id) { this.Channels.Remove(id); this.InvitedChannels.Remove(id); var idx = this.Plugin.ConfigInfo.ChannelOrder .Select(entry => (entry.Key, entry.Value)) .FirstOrDefault(entry => entry.Value == id); if (idx != default) { this.Plugin.ConfigInfo.ChannelOrder.Remove(idx.Key); this.Plugin.SaveConfig(); } } internal async Task Kick(Guid id, string name, ushort world) { var response = await this.QueueMessageAndWait(new RequestKind.Kick(new KickRequest { Channel = id, Name = name, World = world, })); return response switch { ResponseKind.Error { Response.Error: var error } => error, _ => null, }; } internal async Task Promote(Guid id, string name, ushort world, Rank rank) { var resp = await this.QueueMessageAndWait(new RequestKind.Promote(new PromoteRequest { Channel = id, Name = name, World = world, Rank = rank, })); return resp switch { ResponseKind.Error { Response.Error: var error } => error, _ => null, }; } internal async Task Disband(Guid id) { var resp = await this.QueueMessageAndWait(new RequestKind.Disband(new DisbandRequest { Channel = id, })); return resp switch { ResponseKind.Error { Response.Error: var error } => error, _ => null, }; } internal async Task Update(Guid id, UpdateKind kind) { var resp = await this.QueueMessageAndWait(new RequestKind.Update(new UpdateRequest { Channel = id, Kind = kind, })); return resp switch { ResponseKind.Error { Response.Error: var error } => error, ResponseKind.Update => null, _ => throw new Exception("Unexpected response"), }; } internal async Task UpdateToast(Guid id, UpdateKind kind) { if (await this.Update(id, kind) is not { } error) { return; } var name = this.Plugin.ConfigInfo.GetName(id); this.Plugin.ShowError($"Could not update \"{name}\": {error}"); } internal async Task RequestSecrets(Guid id) { await this.QueueMessage(new RequestKind.Secrets(new SecretsRequest { Channel = id, })); } private bool _up; #pragma warning disable CS4014 private async Task Loop() { Start: this._wasConnected = false; this._up = false; this._number = 1; this.WebSocket.Abort(); this.Status = State.Disconnected; if (!this._active) { return; } this.ToSend = System.Threading.Channels.Channel.CreateUnbounded<(RequestContainer, ChannelWriter>?)>(); this._waitersMutex.WaitOne(); this.Waiters = new Dictionary>(); this._waitersMutex.ReleaseMutex(); // If the websocket is closed, we need to reconnect this.WebSocket.Dispose(); this.WebSocket = new ClientWebSocket(); this.Status = State.Connecting; await this.Connect(); Task.Run(async () => { while (this._active && !this._up) { await this.WebSocket.SendMessage(new RequestContainer { Number = IsUpPingNumber, Kind = new RequestKind.Ping(new PingRequest()), }); await Task.Delay(TimeSpan.FromSeconds(1)); } if (this._active && this.Plugin.ConfigInfo.Key != null) { this.AuthenticateAndList(); } }); if (this.Plugin.ConfigInfo.Key == null) { this.Status = State.NotAuthenticated; } var websocketMessage = this.WebSocket.ReceiveMessage(); var toSend = this.ToSend.Reader.ReadAsync().AsTask(); while (this._active && this.WebSocket.State == WebSocketState.Open) { var finished = await Task.WhenAny(websocketMessage, toSend); if (finished == websocketMessage) { var response = await websocketMessage; websocketMessage = this.WebSocket.ReceiveMessage(); switch (response) { case { Kind: ResponseKind.Ping, Number: IsUpPingNumber } when !this._up: { this._up = true; break; } case { Kind: ResponseKind.Message { Response: var resp } }: { Task.Run(() => this.HandleMessage(resp)); break; } case { Kind: ResponseKind.Invited { Response: var resp } }: { Task.Run(() => this.HandleInvited(resp)); break; } case { Kind: ResponseKind.List { Response: var resp } }: { Task.Run(() => this.HandleList(resp)); break; } case { Kind: ResponseKind.MemberChange { Response: var resp } }: { Task.Run(() => this.HandleMemberChange(resp)); break; } case { Kind: ResponseKind.Disband { Response: var resp }, Number: 0 }: { // this is a disband notification, not a response to a command Task.Run(() => this.HandleDisband(resp)); break; } case { Kind: ResponseKind.Updated { Response: var resp }, Number: 0 }: { Task.Run(() => this.HandleUpdated(resp)); break; } case { Kind: ResponseKind.Secrets { Response: var resp } }: { Task.Run(() => this.HandleSecrets(resp)); break; } case { Kind: ResponseKind.SendSecrets { Response: var resp }, Number: 0 }: { Task.Run(async () => await this.HandleSendSecrets(resp)); break; } default: { this._waitersMutex.WaitOne(); try { if (this.Waiters.Remove(response.Number, out var waiter)) { await waiter.WriteAsync(response.Kind); } } finally { this._waitersMutex.ReleaseMutex(); } break; } } } else if (finished == toSend) { var (req, update) = await toSend; toSend = this.ToSend.Reader.ReadAsync().AsTask(); await this.WebSocket.SendMessage(req); if (update != null) { await update.WriteAsync(this.RegisterWaiter(req.Number)); } } } await Task.Delay(TimeSpan.FromSeconds(3)); goto Start; // ReSharper disable once FunctionNeverReturns } #pragma warning restore CS4014 private void HandleSecrets(SecretsResponse resp) { var kx = SodiumKeyExchange.CalculateClientSharedSecret(this.KeyPair.GetPublicKey(), this.GetPrivateKey(), resp.PublicKey); var shared = SecretBox.Decrypt(kx.ReadSharedSecret, resp.EncryptedSharedSecret); this.Plugin.ConfigInfo.GetOrInsertChannel(resp.Channel).SharedSecret = shared; this.Plugin.SaveConfig(); } private async Task HandleSendSecrets(SendSecretsResponse resp) { if (!this.Plugin.ConfigInfo.Channels.TryGetValue(resp.Channel, out var info) || info.SharedSecret.Length == 0) { await this.QueueMessage(new RequestKind.SendSecrets(new SendSecretsRequest { RequestId = resp.RequestId, EncryptedSharedSecret = null, })); return; } var kx = SodiumKeyExchange.CalculateServerSharedSecret(this.KeyPair.GetPublicKey(), this.GetPrivateKey(), resp.PublicKey); var encrypted = SecretBox.Encrypt(kx.TransferSharedSecret, info.SharedSecret); await this.QueueMessage(new RequestKind.SendSecrets(new SendSecretsRequest { RequestId = resp.RequestId, EncryptedSharedSecret = encrypted, })); } private void HandleUpdated(UpdatedResponse resp) { switch (resp.Kind) { case UpdateKind.Name name: { if (this.Plugin.ConfigInfo.Channels.TryGetValue(resp.Channel, out var info)) { var newName = Encoding.UTF8.GetString(SecretBox.Decrypt(info.SharedSecret, name.NewName)); info.Name = newName; this.Plugin.SaveConfig(); } break; } default: { PluginLog.LogWarning($"Unhandled update kind: {resp.Kind}"); break; } } } private void HandleMemberChange(MemberChangeResponse resp) { if (!this.TryGetChannel(resp.Channel, out var channel)) { return; } var channelName = this.Plugin.ConfigInfo.GetName(resp.Channel); var self = this.Plugin.LocalPlayer; var isSelf = self?.Name.TextValue == resp.Name && self.HomeWorld.Id == resp.World; switch (resp.Kind) { case MemberChangeKind.Invite: { channel.Members.Add(new Member { Name = resp.Name, World = resp.World, Rank = Rank.Invited, Online = true, }); break; } case MemberChangeKind.InviteCancel: { channel.Members.RemoveAll( member => member.Name == resp.Name && member.World == resp.World && member.Rank == Rank.Invited ); if (isSelf) { this.ChannelRanks.Remove(resp.Channel); this.InvitedChannels.Remove(resp.Channel); } break; } case MemberChangeKind.InviteDecline: { channel.Members.RemoveAll( member => member.Name == resp.Name && member.World == resp.World && member.Rank == Rank.Invited ); if (isSelf) { this.ChannelRanks.Remove(resp.Channel); this.InvitedChannels.Remove(resp.Channel); } break; } case MemberChangeKind.Join: { var member = channel.Members.FirstOrDefault(member => member.Name == resp.Name && member.World == resp.World); if (member != null) { member.Rank = Rank.Member; } else { channel.Members.Add(new Member { Name = resp.Name, World = resp.World, Rank = Rank.Member, }); } if (isSelf) { this.ChannelRanks[resp.Channel] = Rank.Member; this.Plugin.ShowInfo($"You have joined \"{channelName}\""); } else { var worldName = WorldUtil.WorldName(resp.World); this.Plugin.ShowInfo($"{resp.Name}{PluginUi.CrossWorld}{worldName} has joined \"{channelName}\""); } break; } case MemberChangeKind.Kick: { channel.Members.RemoveAll(member => member.Name == resp.Name && member.World == resp.World); if (isSelf) { this.ChannelRanks.Remove(resp.Channel); this.Plugin.ConfigInfo.RemoveChannelIndex(resp.Channel); this.Plugin.SaveConfig(); this.Plugin.ShowInfo($"You have been kicked from \"{channelName}\""); } else { var worldName = WorldUtil.WorldName(resp.World); this.Plugin.ShowInfo($"{resp.Name}{PluginUi.CrossWorld}{worldName} has been kicked from \"{channelName}\""); } break; } case MemberChangeKind.Leave: { channel.Members.RemoveAll(member => member.Name == resp.Name && member.World == resp.World); if (isSelf) { this.ChannelRanks.Remove(resp.Channel); this.Plugin.ShowInfo($"You have left \"{channelName}\""); } else { var worldName = WorldUtil.WorldName(resp.World); this.Plugin.ShowInfo($"{resp.Name}{PluginUi.CrossWorld}{worldName} has left \"{channelName}\""); } break; } case MemberChangeKind.Promote promote: { bool wasPromotion; var member = channel.Members.FirstOrDefault(member => member.Name == resp.Name && member.World == resp.World); if (member != null) { wasPromotion = promote.Rank >= member.Rank; member.Rank = promote.Rank; } else { wasPromotion = true; channel.Members.Add(new Member { Name = resp.Name, World = resp.World, Rank = promote.Rank, }); } var verb = wasPromotion ? "promoted" : "demoted"; if (isSelf) { this.ChannelRanks[resp.Channel] = promote.Rank; this.Plugin.ShowInfo($"You have been {verb} to {promote.Rank} in \"{channelName}\""); } else { var worldName = WorldUtil.WorldName(resp.World); this.Plugin.ShowInfo($"{resp.Name}{PluginUi.CrossWorld}{worldName} has been {verb} to {promote.Rank} in \"{channelName}\""); } break; } default: { throw new ArgumentOutOfRangeException(); } } } private void HandleDisband(DisbandResponse resp) { if (this.Plugin.ConfigInfo.Channels.TryGetValue(resp.Channel, out var info)) { this.Plugin.ShowInfo($"\"{info.Name}\" has been disbanded."); } this.ActuallyLeave(resp.Channel); } private void HandleList(ListResponse resp) { var self = this.Plugin.LocalPlayer; switch (resp) { case ListResponse.All all: { this.Channels.Clear(); this.InvitedChannels.Clear(); foreach (var channel in all.AllChannels) { this.Channels[channel.Id] = channel; var member = channel.Members .FirstOrDefault(member => member.Name == self?.Name.TextValue && member.World == self.HomeWorld.Id); this.ChannelRanks.Remove(channel.Id); if (member != null) { this.ChannelRanks[channel.Id] = member.Rank; } this.Plugin.ConfigInfo.UpdateChannel(channel); } foreach (var channel in all.AllInvites) { this.InvitedChannels[channel.Id] = channel; this.ChannelRanks[channel.Id] = Rank.Invited; this.Plugin.ConfigInfo.UpdateChannel(channel); this.Plugin.SaveConfig(); } this.Plugin.SaveConfig(); break; } case ListResponse.Channels channels: { foreach (var channel in channels.SimpleChannels) { this.Channels[channel.Id] = new Channel { Id = channel.Id, Name = channel.Name, Members = new List(), }; this.ChannelRanks[channel.Id] = channel.Rank; this.Plugin.ConfigInfo.UpdateChannel(channel); } this.Plugin.SaveConfig(); break; } case ListResponse.Invites invites: { foreach (var channel in invites.AllInvites) { this.InvitedChannels[channel.Id] = new Channel { Id = channel.Id, Name = channel.Name, Members = new List(), }; this.ChannelRanks[channel.Id] = channel.Rank; this.Plugin.ConfigInfo.UpdateChannel(channel); } this.Plugin.SaveConfig(); break; } case ListResponse.Members members: { if (!this.Channels.TryGetValue(members.ChannelId, out var channel)) { break; } channel.Members = members.AllMembers.ToList(); var member = channel.Members .FirstOrDefault(member => member.Name == self?.Name.TextValue && member.World == self.HomeWorld.Id); this.ChannelRanks.Remove(channel.Id); if (member != null) { this.ChannelRanks[channel.Id] = member.Rank; } break; } } this.Plugin.Commands.ReregisterAll(); } private void HandleMessage(MessageResponse resp) { var config = this.Plugin.ConfigInfo; if (!config.Channels.TryGetValue(resp.Channel, out var info)) { return; } var message = SeString.Parse(SecretBox.Decrypt(info.SharedSecret, resp.Message)); var output = new SeStringBuilder(); var colour = config.GetUiColour(resp.Channel); output.AddUiForeground(colour); var marker = config.GetMarker(resp.Channel) ?? "ECLS?"; var isSelf = resp.Sender == this.Plugin.LocalPlayer?.Name.TextValue && resp.World == this.Plugin.LocalPlayer?.HomeWorld.Id; output.AddText($"[{marker}]<"); if (isSelf) { output.AddText(resp.Sender); } else { output.Add(new PlayerPayload(resp.Sender, resp.World)); } if (!isSelf && resp.World != this.Plugin.LocalPlayer?.CurrentWorld.Id) { output.AddIcon(BitmapFontIcon.CrossWorld); var world = this.Plugin.DataManager.GetExcelSheet()?.GetRow(resp.World)?.Name.ToDalamudString(); if (world != null) { foreach (var payload in world.Payloads) { output.Add(payload); } } else { output.AddText($"[Unknown {resp.World}]"); } } output.AddText("> "); foreach (var payload in message.Payloads) { output.Add(payload); } output.AddUiForegroundOff(); if (!this.Plugin.ConfigInfo.ChannelChannels.TryGetValue(resp.Channel, out var outputChannel)) { outputChannel = XivChatType.Debug; } this.Plugin.ChatGui.PrintChat(new XivChatEntry { Message = output.Build(), Name = isSelf ? resp.Sender : new SeString(new PlayerPayload(resp.Sender, resp.World)), Type = outputChannel, }); } private void HandleInvited(InvitedResponse info) { // 1. Decrypt the shared key // 2. Decrypt the channel name var inviter = info.PublicKey; var kx = SodiumKeyExchange.CalculateServerSharedSecret(this.KeyPair.GetPublicKey(), this.GetPrivateKey(), inviter); var shared = SecretBox.Decrypt(kx.ReadSharedSecret, info.EncryptedSecret); var name = Encoding.UTF8.GetString(SecretBox.Decrypt(shared, info.Channel.Name)); this.Plugin.ConfigInfo.Channels[info.Channel.Id] = new ChannelInfo { Name = name, SharedSecret = shared, }; this.InvitedChannels[info.Channel.Id] = info.Channel; this.ChannelRanks[info.Channel.Id] = Rank.Invited; this.Plugin.SaveConfig(); this.Plugin.ShowInfo($"Invited to join \"{name}\" by {info.Name}{PluginUi.CrossWorld}{WorldUtil.WorldName(info.World)}"); } }