Added player detector implementation

Added computer event listeners
This commit is contained in:
Michael Chen 2022-01-17 19:10:14 +01:00
parent 92aafcde70
commit e7b056342f
No known key found for this signature in database
GPG Key ID: 1CBC7AA5671437BB
8 changed files with 188 additions and 42 deletions

View File

@ -73,6 +73,12 @@ local function getResponse(parsed)
return textutils.serializeJSON(item)
elseif parsed.method == "command" then
return runRsCommand(parsed.params)
elseif parsed.method == "getonline" then
return textutils.serializeJSON(getPeripheral("playerDetector").getOnlinePlayers())
elseif parsed.method == "whereis" then
local pos = getPeripheral("playerDetector").getPlayerPos(parsed.params.username)
if not pos then return "null" end
return textutils.serializeJSON(pos)
end
error({message = "No message handler for method: "..parsed.method.."!"})
@ -119,13 +125,7 @@ local function handleMessage(socket, message)
return false
end
local function socketClient()
print("Connecting to the socket server at "..connectionUri.."...")
local socket, reason = http.websocket(connectionUri)
if not socket then error("Socket server could not be reached: "..reason) end
print("Connection successful!")
socket.send("login="..secretToken)
local function responder(socket)
while true do
local message, binary = socket.receive()
if not not message and not binary then
@ -142,15 +142,60 @@ local function termWaiter()
os.pullEvent("terminate")
end
local function services()
parallel.waitForAny(termWaiter, function()
parallel.waitForAll(socketClient)
end)
local function chatEventListener(socket)
while true do
event, username, message, uuid, hidden = os.pullEvent("chat")
sendJson(socket, {type = "chat", username = username, message = message, uuid = uuid, hidden = hidden})
print("Chat event relayed!")
end
end
local function getPeripheralInfo(side)
return {type = peripheral.getType(side), methods = peripheral.getMethods(side), side = side}
end
local function peripheralDetachEventListener(socket)
while true do
event, side = os.pullEvent("peripheral_detach")
sendJson(socket, {type = "peripheral_detach", side = side})
print("Peripheral was detached!")
end
end
local function peripheralAttachEventListener(socket)
while true do
event, side = os.pullEvent("peripheral")
sendJson(socket, {type = "peripheral", peripheral = getPeripheralInfo(side) })
print("Peripheral was attached!")
end
end
local function eventListeners(socket)
parallel.waitForAny(
termWaiter,
function() chatEventListener(socket) end,
function() peripheralDetachEventListener(socket) end,
function() peripheralAttachEventListener(socket) end
)
end
local function socketClient()
print("Connecting to the socket server at "..connectionUri.."...")
local socket, reason = http.websocket(connectionUri)
if not socket then error("Socket server could not be reached: "..reason) end
print("Connection successful!")
socket.send("login="..secretToken)
parallel.waitForAny(
function() responder(socket) end,
function() eventListeners(socket) end
)
socket.close()
end
local function main()
while true do
local status, error = pcall(services)
local status, error = pcall(socketClient)
if status then break end
printError("An uncaught exception was raised:", error)
printError("Restarting in", waitSeconds, "seconds...")

View File

@ -1,4 +1,5 @@
using Discord.WebSocket;
using MinecraftDiscordBot.Models;
using MinecraftDiscordBot.Services;
using System.Reflection;
using System.Text;
@ -8,6 +9,7 @@ namespace MinecraftDiscordBot.Commands;
public abstract class CommandRouter : ICommandHandler<ResponseType> {
public record struct HandlerStruct(HandleCommandDelegate<ResponseType> Delegate, CommandHandlerAttribute Attribute);
private readonly Dictionary<string, HandlerStruct> _handlers = new();
public abstract string HelpTextPrefix { get; }
public CommandRouter() {
foreach (var method in GetType().GetMethods())

View File

@ -64,12 +64,37 @@ public class ReplyMessage : Message {
public abstract class EventMessage : Message { }
[MessageType(TYPE)]
public class PeripheralDetachEvent : EventMessage {
private const string TYPE = "peripheral_detach";
public override string Type => TYPE;
[JsonProperty("side", Required = Required.Always)]
public string Side { get; set; } = default!;
}
[MessageType(TYPE)]
public class PeripheralAttachEvent : EventMessage {
private const string TYPE = "peripheral";
public override string Type => TYPE;
[JsonIgnore]
public string Side => Peripheral.Side;
[JsonProperty("peripheral", Required = Required.Always)]
public Peripheral Peripheral { get; set; } = default!;
}
public class Peripheral {
[JsonProperty("side", Required = Required.Always)]
public string Side { get; set; } = default!;
[JsonProperty("type", Required = Required.Always)]
public string Type { get; set; } = default!;
[JsonProperty("methods", Required = Required.Always)]
public string[] Methods { get; set; } = default!;
}
[MessageType(TYPE)]
public class ChatEvent : EventMessage {
private const string TYPE = "chat";
public override string Type => TYPE;
[JsonProperty("name", Required = Required.Always)]
public string Name { get; set; } = default!;
[JsonProperty("username", Required = Required.Always)]
public string Username { get; set; } = default!;
[JsonProperty("message", Required = Required.Always)]

View File

@ -0,0 +1,13 @@
using Newtonsoft.Json;
namespace MinecraftDiscordBot.Models;
public class PlayerPosition {
[JsonProperty("dimension", Required = Required.Always)] public string Dimension { get; set; } = default!;
[JsonProperty("eyeHeight", Required = Required.Always)] public double EyeHeight { get; set; }
[JsonProperty("pitch", Required = Required.Always)] public double Pitch { get; set; }
[JsonProperty("yaw", Required = Required.Always)] public double Yaw { get; set; }
[JsonProperty("x", Required = Required.Always)] public int X { get; set; }
[JsonProperty("y", Required = Required.Always)] public int Y { get; set; }
[JsonProperty("z", Required = Required.Always)] public int Z { get; set; }
}

View File

@ -27,8 +27,9 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
private readonly ConcurrentDictionary<Guid, RootCommandService> _connections = new();
private static readonly char[] WhiteSpace = new char[] { '\t', '\n', ' ', '\r' };
public ITextChannel[] _channels = Array.Empty<ITextChannel>();
private RootCommandService? _rsSystem = null;
private bool disposedValue;
private readonly RootCommandService _computer;
public static bool OnlineNotifications => true;
private const string ClientScriptName = "MinecraftDiscordBot.ClientScript.lua";
public readonly string ClientScript;
@ -46,21 +47,10 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
.Replace("$HOST", $"ws://{config.SocketHost}:{config.Port}");
}
public RootCommandService? Computer {
get => _rsSystem; set {
if (_rsSystem != value) {
_rsSystem = value;
if (OnlineNotifications)
_ = Task.Run(() => Broadcast(i => i.SendMessageAsync(value is null
? $"The Minecraft client has gone offline!"
: $"The Minecraft client is now online!")));
}
}
}
private async Task Broadcast(Func<ITextChannel, Task<IUserMessage>> message) => _ = await Task.WhenAll(_channels.Select(message));
public Program(BotConfiguration config) {
_config = config;
_computer = new(this);
_administrators = config.Administrators.ToHashSet();
ClientScript = GetClientScript(config);
_client.Log += LogAsync;
@ -142,17 +132,21 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
await (message switch {
"getcode" => SendClientCode(socket),
string s when s.StartsWith("login=") => ClientComputerConnected(socket, s[6..]),
string s when s.StartsWith("error=") => ClientComputerError(socket, s[6..]),
_ => DisruptClientConnection(socket, "Protocol violation!")
});
}
private static async Task ClientComputerError(IWebSocketConnection socket, string message)
=> await LogWarningAsync("Client", $"Computer failed to run the script: {message}");
private async Task ClientComputerConnected(IWebSocketConnection socket, string token) {
if (!_tokenProvider.VerifyToken(token)) {
await DisruptClientConnection(socket, "outdated");
return;
}
await LogInfoAsync(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Client logged in with valid script!");
AddComputerSocket(socket, new(socket, this));
AddComputerSocket(socket);
}
private static async Task DisruptClientConnection(IWebSocketConnection socket, string reason) {
@ -166,10 +160,10 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
await LogInfoAsync(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Script sent to client!");
}
private void AddComputerSocket(IWebSocketConnection socket, RootCommandService pc) => Computer = pc;
private void AddComputerSocket(IWebSocketConnection socket) => _computer.Socket = socket;
private void RemoveComputerSocket(IWebSocketConnection socket) {
if (Computer is { ConnectionInfo.Id: Guid id } && id == socket.ConnectionInfo.Id) Computer = null;
if (_computer.Socket is { ConnectionInfo.Id: Guid id } && id == socket.ConnectionInfo.Id) _computer.Socket = null;
}
private async Task SocketClosed(IWebSocketConnection socket) {
@ -232,7 +226,7 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
}
public async Task<ResponseType> HandleCommand(SocketUserMessage message, string[] parameters, CancellationToken ct) {
if (Computer is ICommandHandler<ResponseType> handler)
if (_computer is ICommandHandler<ResponseType> handler)
try {
return await handler.HandleCommand(message, parameters, ct);
} catch (TaskCanceledException) {

View File

@ -0,0 +1,37 @@
using Discord.WebSocket;
using MinecraftDiscordBot.Commands;
using MinecraftDiscordBot.Models;
using Newtonsoft.Json;
namespace MinecraftDiscordBot.Services;
internal class PlayerDetectorService : CommandRouter {
private readonly ITaskWaitSource _taskSource;
public PlayerDetectorService(ITaskWaitSource taskSource) => _taskSource = taskSource;
public override string HelpTextPrefix => "!pd ";
public override Task<ResponseType> FallbackHandler(SocketUserMessage message, string method, string[] parameters, CancellationToken ct)
=> throw new ReplyException($"The player detector cannot do '{method}'!");
private Task<T> Method<T>(string methodName, Func<string, T> parser, CancellationToken ct, Dictionary<string, object>? parameters = null)
=> RootCommandService.Method(_taskSource, methodName, parser, ct, parameters);
public Task<string[]> GetOnlinePlayers(CancellationToken ct) => Method("getonline", RootCommandService.Deserialize<string[]>(), ct);
public async Task<PlayerPosition> GetPlayerPosition(string username, CancellationToken ct)
=> (await FindPlayer(username, ct)) ?? throw new ReplyException($"User '{username}' is not online!");
private Task<PlayerPosition?> FindPlayer(string username, CancellationToken ct) => Method("whereis", i => JsonConvert.DeserializeObject<PlayerPosition?>(i), ct, new() {
["username"] = username
});
[CommandHandler("getonline", HelpText ="Get a list of online players.")]
public async Task<ResponseType> HandleOnlinePlayers(SocketUserMessage message, string[] parameters, CancellationToken ct)
=> ResponseType.AsString($"The following players are currently online:\n{string.Join("\n", await GetOnlinePlayers(ct))}");
[CommandHandler("whereis", HelpText = "Find a player in the world.")]
public async Task<ResponseType> HandleFindPlayers(SocketUserMessage message, string[] parameters, CancellationToken ct) {
if (parameters is not { Length: 1 }) throw new ReplyException($"Give me only one username!");
var username = parameters[0];
var player = await FindPlayer(username, ct);
if (player is null) throw new ReplyException($"{username} is currently offline!");
return ResponseType.AsString($"{username} is at coordinates {player.X} {player.Y} {player.Z} in dimension {player.Dimension}.");
}
}

View File

@ -18,11 +18,8 @@ public class RefinedStorageService : CommandRouter {
public override Task<ResponseType> FallbackHandler(SocketUserMessage message, string method, string[] parameters, CancellationToken ct)
=> throw new ReplyException($"The RS system has no command '{method}'!");
private async Task<T> Method<T>(string methodName, Func<string, T> parser, CancellationToken ct, Dictionary<string, object>? parameters = null) {
var waiter = _taskSource.GetWaiter(parser, ct);
await _taskSource.Send(new RequestMessage(waiter.ID, methodName, parameters));
return await waiter.Task;
}
private Task<T> Method<T>(string methodName, Func<string, T> parser, CancellationToken ct, Dictionary<string, object>? parameters = null)
=> RootCommandService.Method<T>(_taskSource, methodName, parser, ct, parameters);
private const string CmdEnergyUsage = "energyusage";
private const string CmdEnergyStorage = "energystorage";

View File

@ -10,16 +10,45 @@ public delegate Task<TResponse> HandleCommandDelegate<TResponse>(SocketUserMessa
public delegate Task HandleCommandDelegate(SocketUserMessage message, string[] parameters, CancellationToken ct);
public class RootCommandService : CommandRouter, ITaskWaitSource {
protected readonly IWebSocketConnection _socket;
protected IWebSocketConnection? _socketField;
public override string HelpTextPrefix => "!";
public RootCommandService(IWebSocketConnection socket, IUserRoleManager roleManager) : base() {
socket.OnMessage = OnMessage;
_socket = socket;
public RootCommandService(IUserRoleManager roleManager) : base() {
_rs = new RefinedStorageService(this, roleManager);
_pd = new PlayerDetectorService(this);
}
public static async Task<T> Method<T>(ITaskWaitSource taskSource, string methodName, Func<string, T> parser, CancellationToken ct, Dictionary<string, object>? parameters) {
var waiter = taskSource.GetWaiter(parser, ct);
await taskSource.Send(new RequestMessage(waiter.ID, methodName, parameters));
return await waiter.Task;
}
public event EventHandler<ChatEvent>? ChatMessageReceived;
public event EventHandler<PeripheralAttachEvent>? PeripheralAttached;
public event EventHandler<PeripheralDetachEvent>? PeripheralDetached;
public event EventHandler<IWebSocketConnection?>? SocketChanged;
public IWebSocketConnection? Socket {
get => _socketField; set {
if (_socketField != value) {
_socketField = value;
if (value is not null) value.OnMessage = OnMessage;
SocketChanged?.Invoke(this, value);
}
}
}
private void OnMessage(string message) {
switch (Message.Deserialize(message)) {
case ChatEvent msg:
ChatMessageReceived?.Invoke(this, msg);
break;
case PeripheralAttachEvent msg:
PeripheralAttached?.Invoke(this, msg);
break;
case PeripheralDetachEvent msg:
PeripheralDetached?.Invoke(this, msg);
break;
case ReplyMessage msg:
IChunkWaiter? waiter;
lock (_syncRoot) if (!_waits.TryGetValue(msg.AnswerId, out waiter)) {
@ -38,12 +67,11 @@ public class RootCommandService : CommandRouter, ITaskWaitSource {
}
}
public Task Send(string message) => _socket.Send(message);
public Task Send(string message) => (Socket ?? throw new ReplyException("Minecraft server is not available!")).Send(message);
public Task Send(Message message) => Send(JsonConvert.SerializeObject(message));
private readonly object _syncRoot = new();
private readonly Dictionary<int, IChunkWaiter> _waits = new();
private readonly Random _rnd = new();
public IWebSocketConnectionInfo ConnectionInfo => _socket.ConnectionInfo;
private int GetFreeId() {
var attempts = 0;
@ -65,9 +93,14 @@ public class RootCommandService : CommandRouter, ITaskWaitSource {
}
private readonly ICommandHandler<ResponseType> _rs;
private readonly ICommandHandler<ResponseType> _pd;
[CommandHandler("rs", HelpText = "Provides some commands for interacting with the Refined Storage system.")]
public Task<ResponseType> RefinedStorageHandler(SocketUserMessage message, string[] parameters, CancellationToken ct)
=> _rs.HandleCommand(message, parameters, ct);
[CommandHandler("pd", HelpText = "Provides some commands for interacting with the Player Detector.")]
public Task<ResponseType> PlayerDetectorHandler(SocketUserMessage message, string[] parameters, CancellationToken ct)
=> _pd.HandleCommand(message, parameters, ct);
public static Func<string, T> Deserialize<T>() => msg
=> JsonConvert.DeserializeObject<T>(msg) ?? throw new InvalidProgramException("Empty response!");