using Lumina.Excel.GeneratedSheets; using MessagePack; using Sodium; using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; using Dalamud.Game; using Dalamud.Game.Text; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Logging; using XIVChatCommon; using XIVChatCommon.Message; using XIVChatCommon.Message.Client; using XIVChatCommon.Message.Server; namespace XIVChatPlugin { internal class Server : IDisposable { private const int MaxMessageLength = 500; private static readonly string[] PublicPrefixes = { "/t ", "/tell ", "/reply ", "/r ", "/say ", "/s ", "/shout ", "/sh ", "/yell ", "/y ", }; private readonly Plugin _plugin; private readonly Stopwatch _sendWatch = new(); private readonly CancellationTokenSource _tokenSource = new(); private readonly ConcurrentQueue _toGame = new(); private readonly ConcurrentDictionary _clients = new(); internal IReadOnlyDictionary Clients => this._clients; internal readonly Channel>> PendingClients = Channel.CreateUnbounded>>(); private readonly HashSet _waitingForFriendList = new(); private readonly LinkedList _backlog = new(); private TcpListener? _listener; private bool _sendPlayerData; private readonly ConcurrentQueue _awaitingPlayerData = new(); private readonly ConcurrentQueue _awaitingAvailability = new(); private readonly ConcurrentQueue _awaitingHousingLocation = new(); private volatile bool _running; private bool Running => this._running; private InputChannel _currentChannel = InputChannel.Say; private SeString? _currentChannelName; private ServerHousingLocation _lastHousingLocation; private const int MaxMessageSize = 128_000; internal Server(Plugin plugin) { this._plugin = plugin; if (this._plugin.Config.KeyPair == null) { this.RegenerateKeyPair(); } this._lastHousingLocation = this._plugin.Functions.HousingLocation; this._sendWatch.Start(); this._plugin.Functions.ReceiveFriendList += this.OnReceiveFriendList; } private void SpawnPairingModeTask() { Task.Run(async () => { // delay for 10 seconds because of the jank way we cancel below to prevent port bind issues await Task.Delay(10_000); const int multicastPort = 17444; using var udp = new UdpClient(); udp.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); udp.Client.Bind(new IPEndPoint(IPAddress.Any, multicastPort)); var multicastAddr = IPAddress.Parse("224.0.0.147"); udp.JoinMulticastGroup(multicastAddr); SeString? lastPlayerName = null; Task? receiveTask = null; while (this.Running) { if (!this._plugin.Config.PairingMode) { await Task.Delay(5_000); continue; } var playerName = this._plugin.ClientState.LocalPlayer?.Name; if (playerName != null) { lastPlayerName = playerName; } if (lastPlayerName == null) { await Task.Delay(5_000); continue; } receiveTask ??= udp.ReceiveAsync(); var result = await Task.WhenAny( receiveTask, Task.Delay(1_500) ); if (result != receiveTask) { if (!this.Running) { udp.Close(); } continue; } var recv = await receiveTask; receiveTask = null; var data = recv.Buffer; if (data.Length != 1 || data[0] != 14) { continue; } var utf8 = Encoding.UTF8.GetBytes(lastPlayerName.TextValue); var portBytes = BitConverter.GetBytes(this._plugin.Config.Port).Reverse().ToArray(); var key = this._plugin.Config.KeyPair!.PublicKey; // magic + string length + string + port + key var payload = new byte[1 + 1 + utf8.Length + portBytes.Length + key.Length]; // assuming names can only be 32 bytes here payload[0] = 14; payload[1] = (byte) utf8.Length; Array.Copy(utf8, 0, payload, 2, utf8.Length); Array.Copy(portBytes, 0, payload, 2 + utf8.Length, portBytes.Length); Array.Copy(key, 0, payload, 2 + utf8.Length + portBytes.Length, key.Length); await udp.SendAsync(payload, payload.Length, recv.RemoteEndPoint); } PluginLog.Log("Scan response thread done"); }); } private async void OnReceiveFriendList(List friends) { var msg = new ServerPlayerList(PlayerListType.Friend, friends.ToArray()); foreach (var id in this._waitingForFriendList) { if (!this.Clients.TryGetValue(id, out var client)) { continue; } await client.Queue.Writer.WriteAsync(msg); } this._waitingForFriendList.Clear(); } internal void Spawn() { var port = this._plugin.Config.Port; Task.Run(async () => { this._listener = new TcpListener(IPAddress.Any, port); this._listener.Start(); this._running = true; PluginLog.Log("Running..."); this.SpawnPairingModeTask(); while (!this._tokenSource.IsCancellationRequested) { var conn = await this._listener.GetTcpClient(this._tokenSource); if (conn == null) { continue; } var client = new TcpConnected(conn); this.SpawnClientTask(client, true); } this._running = false; }); } internal void RegenerateKeyPair() { this._plugin.Config.KeyPair = PublicKeyBox.GenerateKeyPair(); this._plugin.Config.Save(); } internal void OnChat(XivChatType type, uint senderId, ref SeString sender, ref SeString message, ref bool isHandled) { if (isHandled) { return; } var chatCode = new ChatCode((ushort) type); if (!this._plugin.Config.SendBattle && chatCode.IsBattle()) { return; } var chunks = new List(); var colour = this._plugin.Functions.GetChannelColour(chatCode) ?? chatCode.DefaultColour(); if (sender.Payloads.Count > 0) { var format = this.FormatFor(chatCode.Type); if (format is { IsPresent: true }) { chunks.Add(new TextChunk(format.Before) { FallbackColour = colour, }); chunks.AddRange(ToChunks(sender, colour)); chunks.Add(new TextChunk(format.After) { FallbackColour = colour, }); } } chunks.AddRange(ToChunks(message, colour)); var msg = new ServerMessage( DateTime.UtcNow, (ChatType) type, sender.Encode(), message.Encode(), chunks ); this._backlog.AddLast(msg); while (this._backlog.Count > this._plugin.Config.BacklogCount) { this._backlog.RemoveFirst(); } foreach (var client in this._clients.Values) { client.Queue.Writer.TryWrite(msg); } } internal void OnFrameworkUpdate(Framework framework1) { var player = this._plugin.ClientState.LocalPlayer; if (player != null && this._sendPlayerData) { this.BroadcastPlayerData(); this._sendPlayerData = false; } var housingLocation = this._plugin.Functions.HousingLocation; if (!Equals(housingLocation, this._lastHousingLocation)) { this.BroadcastMessage(housingLocation, ClientPreference.HousingLocationSupport); this._lastHousingLocation = housingLocation; } while (this._awaitingPlayerData.TryDequeue(out var id)) { if (!this.Clients.TryGetValue(id, out var client)) { continue; } var playerData = (Encodable?) this.GeneratePlayerData() ?? EmptyPlayerData.Instance; client.Queue.Writer.TryWrite(playerData); } while (this._awaitingAvailability.TryDequeue(out var id)) { if (!this.Clients.TryGetValue(id, out var client) || client.Handshake == null) { continue; } var available = player != null; client.Queue.Writer.TryWrite(new Availability(available)); } while (this._awaitingHousingLocation.TryDequeue(out var id)) { if (!this.Clients.TryGetValue(id, out var client) || client.Handshake == null) { continue; } client.Queue.Writer.TryWrite(this._lastHousingLocation); } int time; if (this._toGame.TryPeek(out var peek) && PublicPrefixes.Any(prefix => peek.StartsWith(prefix))) { time = 1_000; } else if (this._currentChannel is InputChannel.Tell or InputChannel.Say or InputChannel.Shout or InputChannel.Yell) { time = 1_000; } else { time = 250; } if (this._sendWatch.Elapsed < TimeSpan.FromMilliseconds(time)) { return; } if (!this._toGame.TryDequeue(out var message)) { return; } this._sendWatch.Restart(); this._plugin.Functions.ProcessChatBox(message); } private static readonly IReadOnlyList Magic = new byte[] { 14, 20, 67, }; internal void SpawnClientTask(BaseClient client, bool requiresMagic) { var id = Guid.NewGuid(); this._clients[id] = client; Task.Run(async () => { if (requiresMagic) { // get ready for reading magic bytes var magic = new byte[Magic.Count]; var read = 0; // only listen for magic for five seconds using var cts = new CancellationTokenSource(); cts.CancelAfter(TimeSpan.FromSeconds(5)); // read magic bytes while (read < magic.Length) { if (cts.IsCancellationRequested) { return; } read += await client.ReadAsync(magic, read, magic.Length - read, cts.Token); } // ignore this connection if incorrect magic bytes if (!magic.SequenceEqual(Magic)) { return; } } var handshake = await KeyExchange.ServerHandshake(this._plugin.Config.KeyPair!, client); client.Handshake = handshake; // if this public key isn't trusted, prompt first if (!this._plugin.Config.TrustedKeys.Values.Any(entry => entry.Item2.SequenceEqual(handshake.RemotePublicKey))) { // if configured to not accept new clients, reject connection if (!this._plugin.Config.AcceptNewClients) { return; } var accepted = Channel.CreateBounded(1); await this.PendingClients.Writer.WriteAsync(Tuple.Create(client, accepted), this._tokenSource.Token); if (!await accepted.Reader.ReadAsync(this._tokenSource.Token)) { return; } } client.Connected = true; // queue sending availability for this client this._awaitingAvailability.Enqueue(id); // queue sending player data for this client this._awaitingPlayerData.Enqueue(id); // send current channel try { var channel = this._currentChannel; await SecretMessage.SendSecretMessage( client, handshake.Keys.tx, new ServerChannel( channel, this._currentChannelName?.TextValue ?? this.LocalisedChannelName(channel) ), this._tokenSource.Token ); } catch (Exception ex) { PluginLog.LogError($"Could not send message: {ex.Message}"); } var listen = Task.Run(async () => { while (this._clients.TryGetValue(id, out var client) && client.Connected && !client.TokenSource.IsCancellationRequested) { byte[] msg; try { msg = await SecretMessage.ReadSecretMessage(client, handshake.Keys.rx, client.TokenSource.Token); } catch (SocketException ex) when (ex.SocketErrorCode == SocketError.TimedOut) { continue; } catch (Exception ex) { PluginLog.LogError($"Could not read message: {ex.Message}"); continue; } await this.ProcessMessage(id, client, msg); } }); this._plugin.Events.FireNewClientEvent(id, client); while (this._clients.TryGetValue(id, out var client) && client.Connected && !client.TokenSource.IsCancellationRequested) { try { var msg = await client.Queue.Reader.ReadAsync(client.TokenSource.Token); await SecretMessage.SendSecretMessage(client, handshake.Keys.tx, msg, client.TokenSource.Token); } catch (Exception ex) { PluginLog.LogError($"Could not send message: {ex.Message}"); } } client.Disconnect(); await listen; this._clients.TryRemove(id, out _); PluginLog.Log($"Client thread ended: {id}"); }).ContinueWith(_ => { this.RemoveClient(id); }); } internal void RemoveClient(Guid id) { if (!this._clients.TryRemove(id, out var client)) { return; } client.Disconnect(); } private async Task ProcessMessage(Guid id, BaseClient client, byte[] msg) { var op = (ClientOperation) msg[0]; var payload = new byte[msg.Length - 1]; Array.Copy(msg, 1, payload, 0, payload.Length); switch (op) { case ClientOperation.Ping: try { await client.Queue.Writer.WriteAsync(Pong.Instance); } catch (Exception ex) { PluginLog.LogError($"Could not send message: {ex.Message}"); } break; case ClientOperation.Message: var clientMessage = ClientMessage.Decode(payload); var sanitised = clientMessage.Content .Replace("\r\n", " ") .Replace('\r', ' ') .Replace('\n', ' '); foreach (var part in Wrap(sanitised)) { this._toGame.Enqueue(part); } break; case ClientOperation.Shutdown: client.Disconnect(); break; case ClientOperation.Backlog: // ReSharper disable once LocalVariableHidesMember var backlog = ClientBacklog.Decode(payload); var backlogMessages = new List(); var node = this._backlog.Last; while (node != null) { if (backlogMessages.Count >= backlog.Amount) { break; } backlogMessages.Add(node.Value); node = node.Previous; } if (!client.GetPreference(ClientPreference.BacklogNewestMessagesFirst, false)) { backlogMessages.Reverse(); } await SendBacklogs(backlogMessages.ToArray(), client); break; case ClientOperation.CatchUp: var catchUp = ClientCatchUp.Decode(payload); // I'm not sure why this needs to be done, but apparently it does var after = catchUp.After.AddMilliseconds(1); var msgs = this.MessagesAfter(after); if (client.GetPreference(ClientPreference.BacklogNewestMessagesFirst, false)) { msgs = msgs.Reverse(); } await SendBacklogs(msgs, client); break; case ClientOperation.PlayerList: var playerList = ClientPlayerList.Decode(payload); if (playerList.Type == PlayerListType.Friend) { this._waitingForFriendList.Add(id); if (!this._plugin.Functions.RequestingFriendList && !this._plugin.Functions.RequestFriendList()) { this._plugin.ChatGui.PrintError($"[{this._plugin.Name}] Please open your friend list to enable friend list support. You should only need to do this on initial install or after updates."); } } break; case ClientOperation.Preferences: var preferences = ClientPreferences.Decode(payload); client.Preferences = preferences; // immediately queue housing location if (client.GetPreference(ClientPreference.HousingLocationSupport, false)) { this._awaitingHousingLocation.Enqueue(id); } break; case ClientOperation.Channel: var channel = ClientChannel.Decode(payload); this._plugin.Functions.ChangeChatChannel(channel.Channel); break; } } internal class NameFormatting { internal string Before { get; private set; } = string.Empty; internal string After { get; private set; } = string.Empty; internal bool IsPresent { get; private set; } = true; internal static NameFormatting Empty() { return new() { IsPresent = false, }; } internal static NameFormatting Of(string before, string after) { return new() { Before = before, After = after, }; } } private Dictionary Formats { get; } = new(); private NameFormatting? FormatFor(ChatType type) { if (this.Formats.TryGetValue(type, out var cached)) { return cached; } var logKind = this._plugin.DataManager.GetExcelSheet()!.GetRow((ushort) type); if (logKind == null) { return null; } var format = (SeString) logKind.Format; static bool IsStringParam(Payload payload, byte num) { var data = payload.Encode(); return data.Length >= 5 && data[1] == 0x29 && data[4] == num + 1; } var firstStringParam = format.Payloads.FindIndex(payload => IsStringParam(payload, 1)); var secondStringParam = format.Payloads.FindIndex(payload => IsStringParam(payload, 2)); if (firstStringParam == -1 || secondStringParam == -1) { return NameFormatting.Empty(); } var before = format.Payloads .GetRange(0, firstStringParam) .Where(payload => payload is ITextProvider) .Cast() .Select(text => text.Text); var after = format.Payloads .GetRange(firstStringParam + 1, secondStringParam - firstStringParam) .Where(payload => payload is ITextProvider) .Cast() .Select(text => text.Text); var nameFormatting = NameFormatting.Of( string.Join("", before), string.Join("", after) ); this.Formats[type] = nameFormatting; return nameFormatting; } private static async Task SendBacklogs(IEnumerable messages, BaseClient client) { const int defaultSize = 5 + SecretMessage.NonceSize + SecretMessage.MacSize; var size = defaultSize; var responseMessages = new List(); async Task SendBacklog() { var resp = new ServerBacklog(responseMessages.ToArray(), ++client.BacklogSequence); try { await client.Queue.Writer.WriteAsync(resp); } catch (Exception ex) { PluginLog.LogError($"Could not send backlog: {ex.Message}"); } } foreach (var catchUpMessage in messages) { // FIXME: this is very gross var len = MessagePackSerializer.Serialize(catchUpMessage).Length; // send message if it would've gone over length if (size + len >= MaxMessageSize) { await SendBacklog(); size = defaultSize; responseMessages.Clear(); } size += len; responseMessages.Add(catchUpMessage); } if (responseMessages.Count > 0) { await SendBacklog(); } } private static IEnumerable ToChunks(SeString msg, uint? defaultColour) { var chunks = new List(); var italic = false; var foreground = new Stack(); var glow = new Stack(); void Append(string text) { chunks.Add(new TextChunk(text) { FallbackColour = defaultColour, Foreground = foreground.Count > 0 ? foreground.Peek() : null, Glow = glow.Count > 0 ? glow.Peek() : null, Italic = italic, }); } foreach (var payload in msg.Payloads) { switch (payload.Type) { case PayloadType.EmphasisItalic: var newStatus = ((EmphasisItalicPayload) payload).IsEnabled; italic = newStatus; break; case PayloadType.UIForeground: var foregroundPayload = (UIForegroundPayload) payload; if (foregroundPayload.IsEnabled) { foreground.Push(foregroundPayload.UIColor.UIForeground); } else if (foreground.Count > 0) { foreground.Pop(); } break; case PayloadType.UIGlow: var glowPayload = (UIGlowPayload) payload; if (glowPayload.IsEnabled) { glow.Push(glowPayload.UIColor.UIGlow); } else if (glow.Count > 0) { glow.Pop(); } break; case PayloadType.AutoTranslateText: chunks.Add(new IconChunk { index = 54, }); var autoText = ((AutoTranslatePayload) payload).Text; Append(autoText.Substring(2, autoText.Length - 4)); chunks.Add(new IconChunk { index = 55, }); break; case PayloadType.Icon: var index = ((IconPayload) payload).Icon; chunks.Add(new IconChunk { index = (byte) index, }); break; case PayloadType.Unknown: var rawPayload = (RawPayload) payload; if (rawPayload.Data[1] == 0x13) { if (foreground.Count > 0) { foreground.Pop(); } if (glow.Count > 0) { glow.Pop(); } } break; default: if (payload is ITextProvider textProvider) { Append(textProvider.Text); } break; } } return chunks; } private IEnumerable MessagesAfter(DateTime time) => this._backlog.Where(msg => msg.Timestamp > time).ToArray(); private static IEnumerable Wrap(string input) { if (input.Length <= MaxMessageLength) { return new[] { input, }; } string prefix = string.Empty; if (input.StartsWith("/")) { var space = input.IndexOf(' '); if (space != -1) { prefix = input[..space]; // handle wrapping tells if (prefix is "/tell" or "/t") { var tellSpace = input.IndexOfCount(' ', 3); if (tellSpace != -1) { prefix = input[..tellSpace]; input = input[(tellSpace + 1)..]; } } else { input = input[(space + 1)..]; } } } return NativeTools.Wrap(input, MaxMessageLength) .Select(text => $"{prefix} {text}") .ToArray(); } private void BroadcastMessage(Encodable message) { foreach (var client in this.Clients.Values) { client.Queue.Writer.TryWrite(message); } } private void BroadcastMessage(Encodable message, ClientPreference preference) { foreach (var client in this.Clients.Values) { if (client.GetPreference(preference, false)) { client.Queue.Writer.TryWrite(message); } } } private string LocalisedChannelName(InputChannel channel) { uint rowId = channel switch { InputChannel.Tell => 3, InputChannel.Say => 1, InputChannel.Party => 4, InputChannel.Alliance => 17, InputChannel.Yell => 16, InputChannel.Shout => 2, InputChannel.FreeCompany => 7, InputChannel.PvpTeam => 19, InputChannel.NoviceNetwork => 18, InputChannel.CrossLinkshell1 => 20, InputChannel.CrossLinkshell2 => 300, InputChannel.CrossLinkshell3 => 301, InputChannel.CrossLinkshell4 => 302, InputChannel.CrossLinkshell5 => 303, InputChannel.CrossLinkshell6 => 304, InputChannel.CrossLinkshell7 => 305, InputChannel.CrossLinkshell8 => 306, InputChannel.Linkshell1 => 8, InputChannel.Linkshell2 => 9, InputChannel.Linkshell3 => 10, InputChannel.Linkshell4 => 11, InputChannel.Linkshell5 => 12, InputChannel.Linkshell6 => 13, InputChannel.Linkshell7 => 14, InputChannel.Linkshell8 => 15, _ => 0, }; return this._plugin.DataManager.GetExcelSheet()!.GetRow(rowId)?.Name ?? string.Empty; } internal void OnChatChannelChange(uint channel, SeString name) { // for now, to avoid changing the protocol further, convert crossworld icon into font icon for (var i = 0; i < name.Payloads.Count; i++) { var payload = name.Payloads[i]; if (payload is IconPayload { Icon: BitmapFontIcon.CrossWorld }) { name.Payloads[i] = new TextPayload("\ue05d"); } } var inputChannel = (InputChannel) channel; if (inputChannel == this._currentChannel && name.Encode().SequenceEqual(this._currentChannelName?.Encode() ?? Array.Empty())) { return; } this._currentChannel = inputChannel; this._currentChannelName = name; var msg = new ServerChannel(inputChannel, name.TextValue); this.BroadcastMessage(msg); } private void BroadcastAvailability(bool available) { this.BroadcastMessage(new Availability(available)); } private PlayerData? GeneratePlayerData() { var player = this._plugin.ClientState.LocalPlayer; if (player == null) { return null; } var homeWorld = player.HomeWorld.GameData.Name; var currentWorld = player.CurrentWorld.GameData.Name; var territory = this._plugin.DataManager.GetExcelSheet()!.GetRow(this._plugin.ClientState.TerritoryType); var location = territory?.PlaceName?.Value?.Name ?? "???"; var name = player.Name.TextValue; return new PlayerData(homeWorld, currentWorld, location, name); } private void BroadcastPlayerData() { var playerData = (Encodable?) this.GeneratePlayerData() ?? EmptyPlayerData.Instance; this.BroadcastMessage(playerData); } internal void OnLogIn(object? sender, EventArgs e) { this.BroadcastAvailability(true); // send player data on next framework update this._sendPlayerData = true; } internal void OnLogOut(object? sender, EventArgs e) { this.BroadcastAvailability(false); this.BroadcastPlayerData(); } internal void OnTerritoryChange(object? sender, ushort territoryId) => this._sendPlayerData = true; public void Dispose() { // stop accepting new clients this._tokenSource.Cancel(); foreach (var client in this._clients.Values) { Task.Run(async () => { // tell clients we're shutting down if (client.Handshake != null) { try { await SecretMessage.SendSecretMessage(client, client.Handshake.Keys.tx, ServerShutdown.Instance); } catch (Exception) { // ignored } } // cancel threads for open clients client.TokenSource.Cancel(); }); } this._plugin.Functions.ReceiveFriendList -= this.OnReceiveFriendList; } } internal static class TcpListenerExt { internal static async Task GetTcpClient(this TcpListener listener, CancellationTokenSource source) { await using (source.Token.Register(listener.Stop)) { try { var client = await listener.AcceptTcpClientAsync().ConfigureAwait(false); return client; } catch (ObjectDisposedException) { // Token was canceled - swallow the exception and return null if (source.Token.IsCancellationRequested) { return null; } throw; } } } } }