770 lines
30 KiB
C#
770 lines
30 KiB
C#
|
using Dalamud.Game.Chat;
|
|||
|
using Dalamud.Game.Chat.SeStringHandling;
|
|||
|
using Dalamud.Game.Chat.SeStringHandling.Payloads;
|
|||
|
using Dalamud.Game.Internal;
|
|||
|
using Dalamud.Plugin;
|
|||
|
using Lumina.Data;
|
|||
|
using Lumina.Excel;
|
|||
|
using Lumina.Excel.GeneratedSheets;
|
|||
|
using MessagePack;
|
|||
|
using Sodium;
|
|||
|
using System;
|
|||
|
using System.Collections.Concurrent;
|
|||
|
using System.Collections.Generic;
|
|||
|
using System.Linq;
|
|||
|
using System.Net;
|
|||
|
using System.Net.Sockets;
|
|||
|
using System.Text;
|
|||
|
using System.Text.RegularExpressions;
|
|||
|
using System.Threading;
|
|||
|
using System.Threading.Channels;
|
|||
|
using System.Threading.Tasks;
|
|||
|
using XIVChatCommon;
|
|||
|
|
|||
|
namespace XIVChatPlugin {
|
|||
|
public class Server : IDisposable {
|
|||
|
private readonly Plugin plugin;
|
|||
|
|
|||
|
private readonly CancellationTokenSource tokenSource = new CancellationTokenSource();
|
|||
|
private readonly ConcurrentQueue<string> toGame = new ConcurrentQueue<string>();
|
|||
|
|
|||
|
private readonly ConcurrentDictionary<Guid, Client> clients = new ConcurrentDictionary<Guid, Client>();
|
|||
|
public IReadOnlyDictionary<Guid, Client> Clients => this.clients;
|
|||
|
public readonly Channel<Tuple<Client, Channel<bool>>> pendingClients = Channel.CreateUnbounded<Tuple<Client, Channel<bool>>>();
|
|||
|
|
|||
|
private readonly HashSet<Guid> WaitingForFriendList = new HashSet<Guid>();
|
|||
|
|
|||
|
private readonly LinkedList<ServerMessage> Backlog = new LinkedList<ServerMessage>();
|
|||
|
|
|||
|
private TcpListener listener;
|
|||
|
|
|||
|
private bool sendPlayerData = false;
|
|||
|
|
|||
|
private volatile bool _running = false;
|
|||
|
public bool Running { get => this._running; set => this._running = value; }
|
|||
|
|
|||
|
private InputChannel currentChannel = InputChannel.Say;
|
|||
|
|
|||
|
private const int MAX_MESSAGE_SIZE = 128_000;
|
|||
|
|
|||
|
public Server(Plugin plugin) {
|
|||
|
this.plugin = plugin ?? throw new ArgumentNullException(nameof(plugin), "Plugin cannot be null");
|
|||
|
if (this.plugin.Config.KeyPair == null) {
|
|||
|
this.RegenerateKeyPair();
|
|||
|
}
|
|||
|
|
|||
|
this.plugin.Functions.ReceiveFriendList += this.OnReceiveFriendList;
|
|||
|
}
|
|||
|
|
|||
|
private async void OnReceiveFriendList(List<Player> friends) {
|
|||
|
var msg = new ServerPlayerList {
|
|||
|
Type = PlayerListType.Friend,
|
|||
|
Players = friends.ToArray(),
|
|||
|
};
|
|||
|
|
|||
|
foreach (var id in this.WaitingForFriendList) {
|
|||
|
if (!this.Clients.TryGetValue(id, out var client)) {
|
|||
|
continue;
|
|||
|
}
|
|||
|
|
|||
|
try {
|
|||
|
await SecretMessage.SendSecretMessage(client.Conn.GetStream(), client.Handshake.Keys.tx, msg, client.TokenSource.Token);
|
|||
|
} catch (Exception ex) {
|
|||
|
PluginLog.LogError($"Could not send message: {ex.Message}");
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
this.WaitingForFriendList.Clear();
|
|||
|
}
|
|||
|
|
|||
|
public void Spawn() {
|
|||
|
var ip = IPAddress.Parse("0.0.0.0");
|
|||
|
var port = this.plugin.Config.Port;
|
|||
|
|
|||
|
Task.Run(async () => {
|
|||
|
this.listener = new TcpListener(ip, port);
|
|||
|
this.listener.Start();
|
|||
|
|
|||
|
this._running = true;
|
|||
|
PluginLog.Log("Running...");
|
|||
|
while (!this.tokenSource.IsCancellationRequested) {
|
|||
|
var conn = await this.listener.GetTcpClient(this.tokenSource);
|
|||
|
this.SpawnClientTask(conn);
|
|||
|
}
|
|||
|
this._running = false;
|
|||
|
});
|
|||
|
}
|
|||
|
|
|||
|
public void RegenerateKeyPair() {
|
|||
|
this.plugin.Config.KeyPair = PublicKeyBox.GenerateKeyPair();
|
|||
|
this.plugin.Config.Save();
|
|||
|
}
|
|||
|
|
|||
|
[System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "delegate")]
|
|||
|
public 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<Chunk>();
|
|||
|
|
|||
|
if (sender.Payloads.Count > 0) {
|
|||
|
// FIXME: can't get format straight from game until Lumina stops returning LogKind.Format as a string (it's an SeString)
|
|||
|
// var format = this.FormatFor(chatCode.Type);
|
|||
|
var format = chatCode.NameFormat();
|
|||
|
if (format != null && format.IsPresent) {
|
|||
|
chunks.Add(new TextChunk {
|
|||
|
FallbackColour = chatCode.DefaultColour(),
|
|||
|
Content = format.Before,
|
|||
|
});
|
|||
|
chunks.AddRange(ToChunks(sender, chatCode.DefaultColour()));
|
|||
|
chunks.Add(new TextChunk {
|
|||
|
FallbackColour = chatCode.DefaultColour(),
|
|||
|
Content = format.After,
|
|||
|
});
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
chunks.AddRange(ToChunks(message, chatCode.DefaultColour()));
|
|||
|
|
|||
|
var msg = new ServerMessage {
|
|||
|
Timestamp = DateTime.UtcNow,
|
|||
|
Channel = (ChatType)type,
|
|||
|
Sender = sender.Encode(),
|
|||
|
Content = message.Encode(),
|
|||
|
Chunks = 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);
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
[System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "delegate")]
|
|||
|
public void OnFrameworkUpdate(Framework framework) {
|
|||
|
if (this.sendPlayerData && this.plugin.Interface.ClientState.LocalPlayer != null) {
|
|||
|
this.BroadcastPlayerData();
|
|||
|
this.sendPlayerData = false;
|
|||
|
}
|
|||
|
|
|||
|
if (!this.toGame.TryDequeue(out var message)) {
|
|||
|
return;
|
|||
|
}
|
|||
|
|
|||
|
this.plugin.Functions.ProcessChatBox(message);
|
|||
|
}
|
|||
|
|
|||
|
private void SpawnClientTask(TcpClient conn) {
|
|||
|
if (conn == null) {
|
|||
|
return;
|
|||
|
}
|
|||
|
|
|||
|
Task.Run(async () => {
|
|||
|
var stream = conn.GetStream();
|
|||
|
|
|||
|
var handshake = await KeyExchange.ServerHandshake(this.plugin.Config.KeyPair, stream);
|
|||
|
var newClient = new Client(conn) {
|
|||
|
Handshake = handshake,
|
|||
|
};
|
|||
|
|
|||
|
// if this public key isn't trusted, prompt first
|
|||
|
if (!this.plugin.Config.TrustedKeys.Values.Any(entry => entry.Item2.SequenceEqual(handshake.RemotePublicKey))) {
|
|||
|
var accepted = Channel.CreateBounded<bool>(1);
|
|||
|
|
|||
|
await this.pendingClients.Writer.WriteAsync(Tuple.Create(newClient, accepted));
|
|||
|
if (!await accepted.Reader.ReadAsync()) {
|
|||
|
return;
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
var id = Guid.NewGuid();
|
|||
|
newClient.Connected = true;
|
|||
|
this.clients[id] = newClient;
|
|||
|
|
|||
|
// send availability
|
|||
|
var available = this.plugin.Interface.ClientState.LocalPlayer != null;
|
|||
|
try {
|
|||
|
await SecretMessage.SendSecretMessage(stream, handshake.Keys.tx, new Availability(available));
|
|||
|
} catch (Exception ex) {
|
|||
|
PluginLog.LogError($"Could not send message: {ex.Message}");
|
|||
|
}
|
|||
|
|
|||
|
// send player data
|
|||
|
try {
|
|||
|
await this.SendPlayerData(newClient);
|
|||
|
} catch (Exception ex) {
|
|||
|
PluginLog.LogError($"Could not send message: {ex.Message}");
|
|||
|
}
|
|||
|
|
|||
|
// send current channel
|
|||
|
try {
|
|||
|
var channel = this.currentChannel;
|
|||
|
await SecretMessage.SendSecretMessage(stream, handshake.Keys.tx, new ServerChannel(
|
|||
|
channel,
|
|||
|
this.LocalisedChannelName(channel)
|
|||
|
));
|
|||
|
} catch (Exception ex) {
|
|||
|
PluginLog.LogError($"Could not send message: {ex.Message}");
|
|||
|
}
|
|||
|
|
|||
|
var listen = Task.Run(async () => {
|
|||
|
conn.ReceiveTimeout = 5_000;
|
|||
|
|
|||
|
while (this.clients.TryGetValue(id, out var client) && client.Connected && !client.TokenSource.IsCancellationRequested && conn.Connected) {
|
|||
|
byte[] msg;
|
|||
|
try {
|
|||
|
msg = await SecretMessage.ReadSecretMessage(stream, 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;
|
|||
|
}
|
|||
|
|
|||
|
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 SecretMessage.SendSecretMessage(stream, handshake.Keys.tx, Pong.Instance, client.TokenSource.Token);
|
|||
|
} catch (Exception ex) {
|
|||
|
PluginLog.LogError($"Could not send message: {ex.Message}");
|
|||
|
}
|
|||
|
break;
|
|||
|
case ClientOperation.Message:
|
|||
|
var clientMessage = ClientMessage.Decode(payload);
|
|||
|
foreach (var part in Wrap(clientMessage.Content)) {
|
|||
|
this.toGame.Enqueue(part);
|
|||
|
}
|
|||
|
break;
|
|||
|
case ClientOperation.Shutdown:
|
|||
|
client.Disconnect();
|
|||
|
break;
|
|||
|
case ClientOperation.Backlog:
|
|||
|
var backlog = ClientBacklog.Decode(payload);
|
|||
|
|
|||
|
var backlogMessages = new List<ServerMessage>();
|
|||
|
|
|||
|
var node = this.Backlog.Last;
|
|||
|
while (node != null) {
|
|||
|
if (backlogMessages.Count >= backlog.Amount) {
|
|||
|
break;
|
|||
|
}
|
|||
|
|
|||
|
backlogMessages.Add(node.Value);
|
|||
|
node = node.Previous;
|
|||
|
}
|
|||
|
|
|||
|
backlogMessages.Reverse();
|
|||
|
|
|||
|
await this.SendBacklogs(backlogMessages.ToArray(), stream, 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);
|
|||
|
|
|||
|
await this.SendBacklogs(msgs, stream, 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.Interface.Framework.Gui.Chat.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;
|
|||
|
}
|
|||
|
}
|
|||
|
});
|
|||
|
|
|||
|
while (this.clients.TryGetValue(id, out var client) && client.Connected && !client.TokenSource.IsCancellationRequested && conn.Connected) {
|
|||
|
try {
|
|||
|
var msg = await client.Queue.Reader.ReadAsync(client.TokenSource.Token);
|
|||
|
await SecretMessage.SendSecretMessage(stream, handshake.Keys.tx, msg, client.TokenSource.Token);
|
|||
|
} catch (Exception ex) {
|
|||
|
PluginLog.LogError($"Could not send message: {ex.Message}");
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
try {
|
|||
|
conn.Close();
|
|||
|
} catch (ObjectDisposedException) { }
|
|||
|
|
|||
|
await listen;
|
|||
|
|
|||
|
this.clients.TryRemove(id, out var _);
|
|||
|
PluginLog.Log($"Client thread ended: {id}");
|
|||
|
});
|
|||
|
}
|
|||
|
|
|||
|
private static readonly Regex colorRegex = new Regex(@"<Color\(.+?\)>", RegexOptions.Compiled);
|
|||
|
private Dictionary<ChatType, NameFormatting> Formats { get; } = new Dictionary<ChatType, NameFormatting>();
|
|||
|
|
|||
|
[Sheet("LogKind")]
|
|||
|
class LogKind : IExcelRow {
|
|||
|
public byte Unknown0;
|
|||
|
public byte[] Format;
|
|||
|
public bool Unknown2;
|
|||
|
|
|||
|
public uint RowId { get; set; }
|
|||
|
public uint SubRowId { get; set; }
|
|||
|
|
|||
|
public void PopulateData(RowParser parser, Lumina.Lumina lumina, Language language) {
|
|||
|
this.RowId = parser.Row;
|
|||
|
this.SubRowId = parser.SubRow;
|
|||
|
|
|||
|
this.Unknown0 = parser.ReadColumn<byte>(0);
|
|||
|
this.Format = parser.ReadColumn<byte[]>(1);
|
|||
|
this.Unknown2 = parser.ReadColumn<bool>(2);
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
private NameFormatting FormatFor(ChatType type) {
|
|||
|
if (this.Formats.TryGetValue(type, out var cached)) {
|
|||
|
return cached;
|
|||
|
}
|
|||
|
|
|||
|
var logKind = this.plugin.Interface.Data.GetExcelSheet<LogKind>().GetRow((ushort)type);
|
|||
|
|
|||
|
if (logKind == null) {
|
|||
|
return null;
|
|||
|
}
|
|||
|
|
|||
|
var sestring = this.plugin.Interface.SeStringManager.Parse(logKind.Format);
|
|||
|
|
|||
|
//PluginLog.Log(string.Join("", Encoding.ASCII.GetBytes(logKind.Format).Select(b => b.ToString("x2"))));
|
|||
|
|
|||
|
//var sestring = this.plugin.Interface.SeStringManager.Parse(Encoding.ASCII.GetBytes(logKind.Format));
|
|||
|
|
|||
|
//var format = colorRegex.Replace(logKind.Format, "")
|
|||
|
// .Replace("</Color>", "")
|
|||
|
// .Replace("<Highlight>", "")
|
|||
|
// .Replace("</Highlight>", "");
|
|||
|
|
|||
|
return NameFormatting.Empty();
|
|||
|
|
|||
|
//if (format.IndexOf("StringParameter(1)") == -1) {
|
|||
|
// return NameFormatting.Empty();
|
|||
|
//}
|
|||
|
|
|||
|
//var parts = format.Split(new string[] { "StringParameter(1)" }, StringSplitOptions.None);
|
|||
|
|
|||
|
//var nameFormatting = NameFormatting.Of(
|
|||
|
// before: parts[0],
|
|||
|
// after: parts[1].Split(new string[] { "StringParameter(2)" }, StringSplitOptions.None)[0]
|
|||
|
//);
|
|||
|
|
|||
|
//this.Formats[type] = nameFormatting;
|
|||
|
|
|||
|
//return nameFormatting;
|
|||
|
}
|
|||
|
|
|||
|
private async Task SendBacklogs(ServerMessage[] messages, NetworkStream stream, Client client) {
|
|||
|
var size = 5 + SecretMessage.MacSize(); // assume 5 bytes for payload lead-in, although it's likely to be less
|
|||
|
var responseMessages = new List<ServerMessage>();
|
|||
|
|
|||
|
async Task SendBacklog() {
|
|||
|
var resp = new ServerBacklog(responseMessages.ToArray());
|
|||
|
try {
|
|||
|
await SecretMessage.SendSecretMessage(stream, client.Handshake.Keys.tx, resp, client.TokenSource.Token);
|
|||
|
} 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 >= MAX_MESSAGE_SIZE) {
|
|||
|
await SendBacklog();
|
|||
|
|
|||
|
size = 5 + SecretMessage.MacSize();
|
|||
|
responseMessages.Clear();
|
|||
|
|
|||
|
}
|
|||
|
size += len;
|
|||
|
responseMessages.Add(catchUpMessage);
|
|||
|
}
|
|||
|
|
|||
|
if (responseMessages.Count > 0) {
|
|||
|
await SendBacklog();
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
private static List<Chunk> ToChunks(SeString msg, uint? defaultColour) {
|
|||
|
var chunks = new List<Chunk>();
|
|||
|
|
|||
|
var italic = false;
|
|||
|
var foreground = new Stack<uint>();
|
|||
|
var glow = new Stack<uint>();
|
|||
|
|
|||
|
void Append(string text) {
|
|||
|
chunks.Add(new TextChunk {
|
|||
|
FallbackColour = defaultColour,
|
|||
|
Foreground = foreground.Count > 0 ? foreground.Peek() : (uint?)null,
|
|||
|
Glow = glow.Count > 0 ? glow.Peek() : (uint?)null,
|
|||
|
Italic = italic,
|
|||
|
Content = text,
|
|||
|
});
|
|||
|
}
|
|||
|
|
|||
|
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).IconIndex;
|
|||
|
chunks.Add(new IconChunk { Index = (byte)index });
|
|||
|
break;
|
|||
|
// FIXME: use ITextProvider directly once it's exposed
|
|||
|
case PayloadType.RawText:
|
|||
|
Append(((TextPayload)payload).Text);
|
|||
|
break;
|
|||
|
case PayloadType.Unknown:
|
|||
|
var rawPayload = (RawPayload)payload;
|
|||
|
if (rawPayload.Data[1] == 0x13) {
|
|||
|
foreground.Pop();
|
|||
|
glow.Pop();
|
|||
|
}
|
|||
|
break;
|
|||
|
//default:
|
|||
|
// var textProviderType = typeof(SeString).Assembly.GetType("Dalamud.Game.Chat.SeStringHandling.ITextProvider");
|
|||
|
// var textProp = textProviderType.GetProperty("Text", BindingFlags.NonPublic | BindingFlags.Instance);
|
|||
|
// var text = (string)textProp.GetValue(payload);
|
|||
|
// append(text);
|
|||
|
// break;
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
return chunks;
|
|||
|
}
|
|||
|
|
|||
|
private ServerMessage[] MessagesAfter(DateTime time) => this.Backlog.Where(msg => msg.Timestamp > time).ToArray();
|
|||
|
|
|||
|
private static string[] Wrap(string input) {
|
|||
|
const int LIMIT = 500;
|
|||
|
|
|||
|
if (input.Length <= LIMIT) {
|
|||
|
return new string[] { input };
|
|||
|
}
|
|||
|
|
|||
|
string prefix = string.Empty;
|
|||
|
if (input.StartsWith("/")) {
|
|||
|
var space = input.IndexOf(' ');
|
|||
|
if (space != -1) {
|
|||
|
prefix = input.Substring(0, space);
|
|||
|
input = input.Substring(space + 1);
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
var parts = new List<string>();
|
|||
|
|
|||
|
var builder = new StringBuilder(LIMIT);
|
|||
|
|
|||
|
foreach (var word in input.Split(' ')) {
|
|||
|
if (word.Length > LIMIT) {
|
|||
|
int wordParts = (int)Math.Ceiling((float)word.Length / LIMIT);
|
|||
|
for (int i = 0; i < wordParts; i++) {
|
|||
|
var start = i == 0 ? 0 : (i * LIMIT);
|
|||
|
var partLength = LIMIT;
|
|||
|
if (prefix.Length != 0) {
|
|||
|
start = start == 0 ? 0 : (start - (prefix.Length + 1) * i);
|
|||
|
partLength = partLength - prefix.Length - 1;
|
|||
|
}
|
|||
|
|
|||
|
var part = word.Length - start < partLength ? word.Substring(start) : word.Substring(start, partLength);
|
|||
|
if (part.Length == 0) {
|
|||
|
continue;
|
|||
|
}
|
|||
|
|
|||
|
if (prefix.Length != 0) {
|
|||
|
part = prefix + " " + part;
|
|||
|
}
|
|||
|
|
|||
|
parts.Add(part);
|
|||
|
}
|
|||
|
continue;
|
|||
|
}
|
|||
|
|
|||
|
if (builder.Length + word.Length > LIMIT) {
|
|||
|
parts.Add(builder.ToString().TrimEnd(' '));
|
|||
|
builder.Clear();
|
|||
|
}
|
|||
|
|
|||
|
if (builder.Length == 0 && prefix.Length != 0) {
|
|||
|
builder.Append(prefix);
|
|||
|
builder.Append(' ');
|
|||
|
}
|
|||
|
|
|||
|
builder.Append(word);
|
|||
|
builder.Append(' ');
|
|||
|
}
|
|||
|
|
|||
|
if (builder.Length != 0) {
|
|||
|
parts.Add(builder.ToString().TrimEnd(' '));
|
|||
|
}
|
|||
|
|
|||
|
return parts.ToArray();
|
|||
|
}
|
|||
|
|
|||
|
private void BroadcastMessage(IEncodable message) {
|
|||
|
foreach (var client in this.Clients.Values) {
|
|||
|
if (client.Handshake == null || client.Conn == null) {
|
|||
|
continue;
|
|||
|
}
|
|||
|
|
|||
|
Task.Run(async () => {
|
|||
|
await SecretMessage.SendSecretMessage(client.Conn.GetStream(), client.Handshake.Keys.tx, message);
|
|||
|
});
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
private string LocalisedChannelName(InputChannel channel) {
|
|||
|
uint rowId;
|
|||
|
switch (channel) {
|
|||
|
case InputChannel.Tell:
|
|||
|
rowId = 3;
|
|||
|
break;
|
|||
|
case InputChannel.Say:
|
|||
|
rowId = 1;
|
|||
|
break;
|
|||
|
case InputChannel.Party:
|
|||
|
rowId = 4;
|
|||
|
break;
|
|||
|
case InputChannel.Alliance:
|
|||
|
rowId = 17;
|
|||
|
break;
|
|||
|
case InputChannel.Yell:
|
|||
|
rowId = 16;
|
|||
|
break;
|
|||
|
case InputChannel.Shout:
|
|||
|
rowId = 2;
|
|||
|
break;
|
|||
|
case InputChannel.FreeCompany:
|
|||
|
rowId = 7;
|
|||
|
break;
|
|||
|
case InputChannel.PvpTeam:
|
|||
|
rowId = 19;
|
|||
|
break;
|
|||
|
case InputChannel.NoviceNetwork:
|
|||
|
rowId = 18;
|
|||
|
break;
|
|||
|
case InputChannel.CrossLinkshell1:
|
|||
|
rowId = 20;
|
|||
|
break;
|
|||
|
case InputChannel.CrossLinkshell2:
|
|||
|
rowId = 300;
|
|||
|
break;
|
|||
|
case InputChannel.CrossLinkshell3:
|
|||
|
rowId = 301;
|
|||
|
break;
|
|||
|
case InputChannel.CrossLinkshell4:
|
|||
|
rowId = 302;
|
|||
|
break;
|
|||
|
case InputChannel.CrossLinkshell5:
|
|||
|
rowId = 303;
|
|||
|
break;
|
|||
|
case InputChannel.CrossLinkshell6:
|
|||
|
rowId = 304;
|
|||
|
break;
|
|||
|
case InputChannel.CrossLinkshell7:
|
|||
|
rowId = 305;
|
|||
|
break;
|
|||
|
case InputChannel.CrossLinkshell8:
|
|||
|
rowId = 306;
|
|||
|
break;
|
|||
|
case InputChannel.Linkshell1:
|
|||
|
rowId = 8;
|
|||
|
break;
|
|||
|
case InputChannel.Linkshell2:
|
|||
|
rowId = 9;
|
|||
|
break;
|
|||
|
case InputChannel.Linkshell3:
|
|||
|
rowId = 10;
|
|||
|
break;
|
|||
|
case InputChannel.Linkshell4:
|
|||
|
rowId = 11;
|
|||
|
break;
|
|||
|
case InputChannel.Linkshell5:
|
|||
|
rowId = 12;
|
|||
|
break;
|
|||
|
case InputChannel.Linkshell6:
|
|||
|
rowId = 13;
|
|||
|
break;
|
|||
|
case InputChannel.Linkshell7:
|
|||
|
rowId = 14;
|
|||
|
break;
|
|||
|
case InputChannel.Linkshell8:
|
|||
|
rowId = 15;
|
|||
|
break;
|
|||
|
default:
|
|||
|
rowId = 0;
|
|||
|
break;
|
|||
|
};
|
|||
|
return this.plugin.Interface.Data.GetExcelSheet<LogFilter>().GetRow(rowId).Name;
|
|||
|
}
|
|||
|
|
|||
|
public void OnChatChannelChange(uint channel) {
|
|||
|
var inputChannel = (InputChannel)channel;
|
|||
|
this.currentChannel = inputChannel;
|
|||
|
|
|||
|
var localisedName = this.LocalisedChannelName(inputChannel);
|
|||
|
|
|||
|
var msg = new ServerChannel(inputChannel, localisedName);
|
|||
|
this.BroadcastMessage(msg);
|
|||
|
}
|
|||
|
|
|||
|
private void BroadcastAvailability(bool available) {
|
|||
|
this.BroadcastMessage(new Availability(available));
|
|||
|
}
|
|||
|
|
|||
|
private PlayerData GeneratePlayerData() {
|
|||
|
var player = this.plugin.Interface.ClientState.LocalPlayer;
|
|||
|
if (player == null) {
|
|||
|
return null;
|
|||
|
}
|
|||
|
|
|||
|
var homeWorld = player.HomeWorld.GameData.Name;
|
|||
|
var currentWorld = player.CurrentWorld.GameData.Name;
|
|||
|
var territory = this.plugin.Interface.Data.GetExcelSheet<TerritoryType>().GetRow(this.plugin.Interface.ClientState.TerritoryType);
|
|||
|
var location = territory.PlaceName.Value.Name;
|
|||
|
var name = player.Name;
|
|||
|
|
|||
|
return new PlayerData(homeWorld, currentWorld, location, name);
|
|||
|
}
|
|||
|
|
|||
|
private async Task SendPlayerData(Client client) {
|
|||
|
var playerData = this.GeneratePlayerData();
|
|||
|
if (playerData == null) {
|
|||
|
return;
|
|||
|
}
|
|||
|
await SecretMessage.SendSecretMessage(client.Conn.GetStream(), client.Handshake.Keys.tx, playerData);
|
|||
|
}
|
|||
|
|
|||
|
private void BroadcastPlayerData() {
|
|||
|
var playerData = this.GeneratePlayerData();
|
|||
|
|
|||
|
if (playerData == null) {
|
|||
|
this.BroadcastMessage(EmptyPlayerData.Instance);
|
|||
|
return;
|
|||
|
}
|
|||
|
|
|||
|
this.BroadcastMessage(playerData);
|
|||
|
}
|
|||
|
|
|||
|
public void OnLogIn(object sender, EventArgs e) {
|
|||
|
this.BroadcastAvailability(true);
|
|||
|
// send player data on next framework update
|
|||
|
this.sendPlayerData = true;
|
|||
|
}
|
|||
|
|
|||
|
public void OnLogOut(object sender, EventArgs e) {
|
|||
|
this.BroadcastAvailability(false);
|
|||
|
this.BroadcastPlayerData();
|
|||
|
}
|
|||
|
|
|||
|
public void OnTerritoryChange(object sender, ushort territoryId) => this.BroadcastPlayerData();
|
|||
|
|
|||
|
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 {
|
|||
|
// time out after 5 seconds
|
|||
|
client.Conn.SendTimeout = 5_000;
|
|||
|
await SecretMessage.SendSecretMessage(client.Conn.GetStream(), client.Handshake.Keys.tx, ServerShutdown.Instance);
|
|||
|
} catch (Exception) { }
|
|||
|
}
|
|||
|
// cancel threads for open clients
|
|||
|
client.TokenSource.Cancel();
|
|||
|
});
|
|||
|
}
|
|||
|
this.plugin.Functions.ReceiveFriendList -= this.OnReceiveFriendList;
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
public class Client {
|
|||
|
public bool Connected { get; set; } = false;
|
|||
|
public TcpClient Conn { get; private set; }
|
|||
|
public HandshakeInfo Handshake { get; set; }
|
|||
|
public CancellationTokenSource TokenSource { get; } = new CancellationTokenSource();
|
|||
|
public Channel<ServerMessage> Queue { get; } = Channel.CreateUnbounded<ServerMessage>();
|
|||
|
|
|||
|
public Client(TcpClient conn) {
|
|||
|
this.Conn = conn;
|
|||
|
}
|
|||
|
|
|||
|
public void Disconnect() {
|
|||
|
this.Connected = false;
|
|||
|
this.TokenSource.Cancel();
|
|||
|
this.Conn.Close();
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
internal static class TcpListenerExt {
|
|||
|
public static async Task<TcpClient> GetTcpClient(this TcpListener listener, CancellationTokenSource source) {
|
|||
|
using (source.Token.Register(listener.Stop)) {
|
|||
|
try {
|
|||
|
var client = await listener.AcceptTcpClientAsync().ConfigureAwait(false);
|
|||
|
return client;
|
|||
|
} catch (ObjectDisposedException ex) {
|
|||
|
// Token was canceled - swallow the exception and return null
|
|||
|
if (source.Token.IsCancellationRequested) {
|
|||
|
return null;
|
|||
|
}
|
|||
|
|
|||
|
throw ex;
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}
|