ExtraChat/client/ExtraChat/Client.cs

951 lines
35 KiB
C#

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<uint, ChannelWriter<ResponseKind>> Waiters { get; set; } = new();
private Channel<(RequestContainer, ChannelWriter<ChannelReader<ResponseKind>>?)> ToSend { get; set; } = System.Threading.Channels.Channel.CreateUnbounded<(RequestContainer, ChannelWriter<ChannelReader<ResponseKind>>?)>();
internal Dictionary<Guid, Channel> Channels { get; } = new();
internal Dictionary<Guid, Channel> InvitedChannels { get; } = new();
internal Dictionary<Guid, Rank> 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<ResponseKind> RegisterWaiter(uint number) {
var channel = System.Threading.Channels.Channel.CreateBounded<ResponseKind>(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<ResponseKind> QueueMessageAndWait(RequestKind request) {
var container = new RequestContainer {
Number = this._number++,
Kind = request,
};
var channel = System.Threading.Channels.Channel.CreateBounded<ChannelReader<ResponseKind>>(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();
}
});
}
/// <summary>
/// Gets the challenge to put in the user's Lodestone profile.
/// </summary>
/// <returns>challenge or null if LocalPlayer is null</returns>
/// <exception cref="Exception">if the server returns an error or unexpected output</exception>
internal async Task<string?> 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<InviteResponse?> 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}");
}
}
/// <summary>
/// Attempts to register the user after the challenge has been completed.
/// </summary>
/// <returns>authentication key or null if LocalPlayer was null or the challenge failed</returns>
/// <exception cref="Exception">if the server returns an error or unexpected output</exception>
internal async Task<string?> 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<bool> 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<string?> 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<string?> 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<string?> 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<string?> 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<ChannelReader<ResponseKind>>?)>();
this._waitersMutex.WaitOne();
this.Waiters = new Dictionary<uint, ChannelWriter<ResponseKind>>();
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<Member>(),
};
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<Member>(),
};
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<World>()?.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)}");
}
}