Redo the message protocols to work with SSE data directly
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace ChatTwo.Http.MessageProtocol;
|
||||||
|
|
||||||
|
public struct SwitchChannel(string name)
|
||||||
|
{
|
||||||
|
[JsonProperty("channel")] public string Name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Messages(MessageResponse[] set)
|
||||||
|
{
|
||||||
|
[JsonProperty("messages")] public MessageResponse[] Set = set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct MessageResponse()
|
||||||
|
{
|
||||||
|
[JsonProperty("timestamp")] public string Timestamp = "";
|
||||||
|
[JsonProperty("messageHTML")] public string Message = "";
|
||||||
|
}
|
||||||
@@ -1,21 +1,22 @@
|
|||||||
using Newtonsoft.Json;
|
using System.Text;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace ChatTwo.Http.MessageProtocol;
|
namespace ChatTwo.Http.MessageProtocol;
|
||||||
|
|
||||||
public struct MessageResponse()
|
public class CloseEvent() : BaseEvent("close");
|
||||||
{
|
|
||||||
[JsonProperty("timestamp")] public string Timestamp = "";
|
|
||||||
[JsonProperty("messageHTML")] public string Message = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
public class NewMessage(MessageResponse[] messages) : BaseMessage(MessageName)
|
public class SwitchChannelEvent(SwitchChannel switchChannel) : BaseEvent("switch-channel", JsonConvert.SerializeObject(switchChannel));
|
||||||
{
|
|
||||||
private const string MessageName = "chat-message";
|
|
||||||
|
|
||||||
[JsonProperty("messages")] public MessageResponse[] Messages { get; set; } = messages;
|
public class NewMessageEvent(Messages messages) : BaseEvent("new-message", JsonConvert.SerializeObject(messages));
|
||||||
}
|
|
||||||
|
|
||||||
public class BaseMessage(string messageType)
|
public class BaseEvent(string eventType, string? data = null)
|
||||||
{
|
{
|
||||||
[JsonProperty("messageType")] public string MessageType { get; set; } = messageType;
|
private string Event = eventType;
|
||||||
|
private string Data = data ?? "0"; // SSE requires data on each response
|
||||||
|
|
||||||
|
public byte[] Build()
|
||||||
|
{
|
||||||
|
// SSE always ends with \n\n
|
||||||
|
return Encoding.UTF8.GetBytes($"event: {Event}\ndata: {Data}\n\n");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -17,13 +17,9 @@ public class Processing
|
|||||||
Plugin = plugin;
|
Plugin = plugin;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string ReadChannelName()
|
public string ReadChannelName(Chunk[] channelName)
|
||||||
{
|
{
|
||||||
var messages = new List<string>();
|
return string.Join("", channelName.Select(chunk => ProcessChunk(chunk, noColor: true)));
|
||||||
foreach (var chunk in Plugin.ChatLogWindow.ReadChannelName(Plugin.ChatLogWindow.CurrentTab))
|
|
||||||
messages.Add(ProcessChunk(chunk, noColor: true));
|
|
||||||
|
|
||||||
return string.Join("", messages);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal async Task<MessageResponse[]> ReadMessageList()
|
internal async Task<MessageResponse[]> ReadMessageList()
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ public class RouteController
|
|||||||
Core.HostContext.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/send", ReceiveMessage, ExceptionRoute);
|
Core.HostContext.Routes.PostAuthentication.Static.Add(HttpMethod.POST, "/send", ReceiveMessage, ExceptionRoute);
|
||||||
Core.HostContext.Routes.PostAuthentication.Parameter.Add(HttpMethod.GET, "/emote/{name}", GetEmote, ExceptionRoute);
|
Core.HostContext.Routes.PostAuthentication.Parameter.Add(HttpMethod.GET, "/emote/{name}", GetEmote, ExceptionRoute);
|
||||||
|
|
||||||
Core.HostContext.Routes.PreAuthentication.Parameter.Add(HttpMethod.GET, "/sse", StartServerEvent, ExceptionRoute);
|
// Server-Sent Events Route
|
||||||
|
Core.HostContext.Routes.PostAuthentication.Static.Add(HttpMethod.GET, "/sse", NewServerEvent, ExceptionRoute);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ExceptionRoute(HttpContextBase ctx, Exception _)
|
private async Task ExceptionRoute(HttpContextBase ctx, Exception _)
|
||||||
@@ -153,7 +154,7 @@ public class RouteController
|
|||||||
await ctx.Response.Send("Message was send to the channel.");
|
await ctx.Response.Send("Message was send to the channel.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task StartServerEvent(HttpContextBase ctx)
|
private async Task NewServerEvent(HttpContextBase ctx)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -164,7 +165,9 @@ public class RouteController
|
|||||||
|
|
||||||
// TODO Check if reconnect or new connection
|
// TODO Check if reconnect or new connection
|
||||||
var messages = await WebserverUtil.FrameworkWrapper(Core.Processing.ReadMessageList);
|
var messages = await WebserverUtil.FrameworkWrapper(Core.Processing.ReadMessageList);
|
||||||
sse.OutboundStack.Push(new NewMessage(messages.ToArray()));
|
var channelName = await Plugin.Framework.RunOnTick(() => Core.Processing.ReadChannelName(Plugin.ChatLogWindow.PreviousChannel));
|
||||||
|
sse.OutboundQueue.Enqueue(new NewMessageEvent(new Messages(messages)));
|
||||||
|
sse.OutboundQueue.Enqueue(new SwitchChannelEvent(new SwitchChannel(channelName)));
|
||||||
|
|
||||||
await sse.HandleEventLoop(ctx);
|
await sse.HandleEventLoop(ctx);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using ChatTwo.Http.MessageProtocol;
|
using ChatTwo.Http.MessageProtocol;
|
||||||
using Newtonsoft.Json;
|
|
||||||
using WatsonWebserver.Core;
|
using WatsonWebserver.Core;
|
||||||
|
|
||||||
namespace ChatTwo.Http;
|
namespace ChatTwo.Http;
|
||||||
@@ -12,7 +11,7 @@ public class SSEConnection
|
|||||||
private readonly CancellationToken Token;
|
private readonly CancellationToken Token;
|
||||||
|
|
||||||
public bool Done;
|
public bool Done;
|
||||||
public readonly Stack<BaseMessage> OutboundStack = new();
|
public readonly Queue<BaseEvent> OutboundQueue = new();
|
||||||
|
|
||||||
public SSEConnection(CancellationToken token)
|
public SSEConnection(CancellationToken token)
|
||||||
{
|
{
|
||||||
@@ -34,11 +33,10 @@ public class SSEConnection
|
|||||||
if (Token.IsCancellationRequested)
|
if (Token.IsCancellationRequested)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (!OutboundStack.TryPop(out var message))
|
if (!OutboundQueue.TryDequeue(out var outgoingEvent))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var data = JsonConvert.SerializeObject(message);
|
await ctx.Response.SendChunk(outgoingEvent.Build(), Token);
|
||||||
await ctx.Response.SendChunk(Encoding.UTF8.GetBytes($"id: {Index}\ndata: {data}\n\n"), Token);
|
|
||||||
Index++;
|
Index++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,12 +46,12 @@ public class SSEConnection
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Plugin.Log.Error(ex, "Event queued failed to continue");
|
Plugin.Log.Error(ex, "SSE handler failed.");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
// "No Content" (204) didn't work for Firefox, so manually closing the connection on client side
|
// "No Content" (204) didn't work for Firefox, so manually closing the connection on client side
|
||||||
await ctx.Response.SendFinalChunk("data: closing\nevent: close\n\n"u8.ToArray());
|
await ctx.Response.SendFinalChunk(new CloseEvent().Build());
|
||||||
|
|
||||||
Done = true;
|
Done = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,14 +45,31 @@ public class ServerCore : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
Plugin.Framework.RunOnTick(() =>
|
Plugin.Framework.RunOnTick(() =>
|
||||||
{
|
{
|
||||||
var bundledMessage = new NewMessage([Processing.ReadMessageContent(message)]);
|
var bundledResponse = new NewMessageEvent(new Messages([Processing.ReadMessageContent(message)]));
|
||||||
foreach (var eventServer in EventConnections)
|
foreach (var eventServer in EventConnections)
|
||||||
eventServer.OutboundStack.Push(bundledMessage);
|
eventServer.OutboundQueue.Enqueue(bundledResponse);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Plugin.Log.Error(ex, "Send message to websockets failed.");
|
Plugin.Log.Error(ex, "Sending message over SSE failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void SendChannelSwitch(Chunk[] channelName)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Plugin.Framework.RunOnTick(() =>
|
||||||
|
{
|
||||||
|
var bundledResponse = new SwitchChannelEvent(new SwitchChannel(Processing.ReadChannelName(channelName)));
|
||||||
|
foreach (var eventServer in EventConnections)
|
||||||
|
eventServer.OutboundQueue.Enqueue(bundledResponse);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Plugin.Log.Error(ex, "Sending channel switch over SSE failed.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
@@ -101,13 +118,13 @@ public class ServerCore : IAsyncDisposable
|
|||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
await TokenSource.CancelAsync();
|
await TokenSource.CancelAsync();
|
||||||
|
HostContext.Stop();
|
||||||
|
|
||||||
foreach (var eventServer in EventConnections)
|
// We get a copy, so that the original can be cleaned up succesfully
|
||||||
|
foreach (var eventServer in EventConnections.ToArray())
|
||||||
await eventServer.DisposeAsync();
|
await eventServer.DisposeAsync();
|
||||||
|
|
||||||
HostContext.Stop();
|
|
||||||
HostContext.Dispose();
|
HostContext.Dispose();
|
||||||
|
|
||||||
RouteController.Dispose();
|
RouteController.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,18 +3,22 @@ class SSEConnection {
|
|||||||
constructor() {
|
constructor() {
|
||||||
this.socket = new EventSource('/sse', );
|
this.socket = new EventSource('/sse', );
|
||||||
|
|
||||||
this.socket.addEventListener('close', (e) => {
|
this.socket.addEventListener('close', (event) => {
|
||||||
console.log("Closing SSE connection.")
|
console.log("Closing SSE connection.")
|
||||||
this.socket.close()
|
this.socket.close()
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.onmessage = (event) => {
|
this.socket.addEventListener('switch-channel', (event) => {
|
||||||
|
updateChannelHint(JSON.parse(event.data).channel)
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.addEventListener('new-message', (event) => {
|
||||||
let eventData = JSON.parse(event.data);
|
let eventData = JSON.parse(event.data);
|
||||||
for (let message of eventData.messages)
|
for (let message of eventData.messages)
|
||||||
{
|
{
|
||||||
addMessage(message);
|
addMessage(message);
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
send(message) {
|
send(message) {
|
||||||
|
|||||||
+22
-16
@@ -68,6 +68,9 @@ public sealed class ChatLogWindow : Window
|
|||||||
private int AutoCompleteSelection;
|
private int AutoCompleteSelection;
|
||||||
private bool AutoCompleteShouldScroll;
|
private bool AutoCompleteShouldScroll;
|
||||||
|
|
||||||
|
// Used to detect channel changes for the webinterface
|
||||||
|
public Chunk[] PreviousChannel = [];
|
||||||
|
|
||||||
public int CursorPos;
|
public int CursorPos;
|
||||||
|
|
||||||
public Vector2 LastWindowPos { get; private set; } = Vector2.Zero;
|
public Vector2 LastWindowPos { get; private set; } = Vector2.Zero;
|
||||||
@@ -227,6 +230,7 @@ public sealed class ChatLogWindow : Window
|
|||||||
Plugin.Log.Warning($"Channel was set to an invalid value '{targetChannel}', ignoring");
|
Plugin.Log.Warning($"Channel was set to an invalid value '{targetChannel}', ignoring");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (info.Permanent)
|
if (info.Permanent)
|
||||||
SetChannel(targetChannel);
|
SetChannel(targetChannel);
|
||||||
else
|
else
|
||||||
@@ -751,6 +755,14 @@ public sealed class ChatLogWindow : Window
|
|||||||
|
|
||||||
private void DrawChannelName(Tab? activeTab)
|
private void DrawChannelName(Tab? activeTab)
|
||||||
{
|
{
|
||||||
|
var currentChannel = ReadChannelName(activeTab);
|
||||||
|
|
||||||
|
if (!currentChannel.SequenceEqual(PreviousChannel))
|
||||||
|
{
|
||||||
|
PreviousChannel = currentChannel;
|
||||||
|
Plugin.ServerCore?.SendChannelSwitch(currentChannel);
|
||||||
|
}
|
||||||
|
|
||||||
DrawChunks(ReadChannelName(activeTab));
|
DrawChunks(ReadChannelName(activeTab));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -766,13 +778,13 @@ public sealed class ChatLogWindow : Window
|
|||||||
playerName = HashPlayer(TellTarget.Name, TellTarget.World);
|
playerName = HashPlayer(TellTarget.Name, TellTarget.World);
|
||||||
var world = WorldSheet.GetRow(TellTarget.World)?.Name?.RawString ?? "???";
|
var world = WorldSheet.GetRow(TellTarget.World)?.Name?.RawString ?? "???";
|
||||||
|
|
||||||
channelNameChunks = new Chunk[]
|
channelNameChunks =
|
||||||
{
|
[
|
||||||
new TextChunk(ChunkSource.None, null, "Tell "),
|
new TextChunk(ChunkSource.None, null, "Tell "),
|
||||||
new TextChunk(ChunkSource.None, null, playerName),
|
new TextChunk(ChunkSource.None, null, playerName),
|
||||||
new IconChunk(ChunkSource.None, null, BitmapFontIcon.CrossWorld),
|
new IconChunk(ChunkSource.None, null, BitmapFontIcon.CrossWorld),
|
||||||
new TextChunk(ChunkSource.None, null, world),
|
new TextChunk(ChunkSource.None, null, world)
|
||||||
};
|
];
|
||||||
}
|
}
|
||||||
else if (TempChannel != null)
|
else if (TempChannel != null)
|
||||||
{
|
{
|
||||||
@@ -804,17 +816,11 @@ public sealed class ChatLogWindow : Window
|
|||||||
//
|
//
|
||||||
// We don't call channel.ToChatType().Name() as it has the
|
// We don't call channel.ToChatType().Name() as it has the
|
||||||
// long name as used in the settings window.
|
// long name as used in the settings window.
|
||||||
channelNameChunks = new Chunk[]
|
channelNameChunks = [new TextChunk(ChunkSource.None, null, channel.IsExtraChatLinkshell() ? $"ECLS [{channel.LinkshellIndex() + 1}]" : channel.ToChatType().Name())];
|
||||||
{
|
|
||||||
new TextChunk(ChunkSource.None, null, channel.IsExtraChatLinkshell() ? $"ECLS [{channel.LinkshellIndex() + 1}]" : channel.ToChatType().Name())
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
else if (Plugin.ExtraChat.ChannelOverride is var (overrideName, _))
|
else if (Plugin.ExtraChat.ChannelOverride is var (overrideName, _))
|
||||||
{
|
{
|
||||||
channelNameChunks = new Chunk[]
|
channelNameChunks = [new TextChunk(ChunkSource.None, null, overrideName)];
|
||||||
{
|
|
||||||
new TextChunk(ChunkSource.None, null, overrideName)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
else if (ScreenshotMode && Plugin.Functions.Chat.Channel is (InputChannel.Tell, _, var tellPlayerName, var tellWorldId))
|
else if (ScreenshotMode && Plugin.Functions.Chat.Channel is (InputChannel.Tell, _, var tellPlayerName, var tellWorldId))
|
||||||
{
|
{
|
||||||
@@ -825,13 +831,13 @@ public sealed class ChatLogWindow : Window
|
|||||||
var playerName = HashPlayer(tellPlayerName, tellWorldId);
|
var playerName = HashPlayer(tellPlayerName, tellWorldId);
|
||||||
var world = WorldSheet.GetRow(tellWorldId)?.Name?.RawString ?? "???";
|
var world = WorldSheet.GetRow(tellWorldId)?.Name?.RawString ?? "???";
|
||||||
|
|
||||||
channelNameChunks = new Chunk[]
|
channelNameChunks =
|
||||||
{
|
[
|
||||||
new TextChunk(ChunkSource.None, null, "Tell "),
|
new TextChunk(ChunkSource.None, null, "Tell "),
|
||||||
new TextChunk(ChunkSource.None, null, playerName),
|
new TextChunk(ChunkSource.None, null, playerName),
|
||||||
new IconChunk(ChunkSource.None, null, BitmapFontIcon.CrossWorld),
|
new IconChunk(ChunkSource.None, null, BitmapFontIcon.CrossWorld),
|
||||||
new TextChunk(ChunkSource.None, null, world),
|
new TextChunk(ChunkSource.None, null, world)
|
||||||
};
|
];
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user