Compare commits

..

No commits in common. "6920d1a2b31d3a1c15ea82d57af71a77becb579e" and "fd3e6fdcc8eb06fee00e619ba7ae9423b6cc8c7a" have entirely different histories.

20 changed files with 162 additions and 703 deletions

View File

@ -21,14 +21,8 @@ public class BotConfiguration : IBotConfiguration, IBotConfigurator {
[Option("prefix", Default = DEFAULT_PREFIX, HelpText = "The Discord bot command prefix")] [Option("prefix", Default = DEFAULT_PREFIX, HelpText = "The Discord bot command prefix")]
public string Prefix { get; init; } = DEFAULT_PREFIX; public string Prefix { get; init; } = DEFAULT_PREFIX;
[JsonProperty("host", Required = Required.Always)] [JsonProperty("host", Required = Required.Always)]
[Option("host", Default = DEFAULT_PREFIX, HelpText = "The external websocket hostname.", Required = true)] [Option("host", Default = DEFAULT_PREFIX, HelpText = "The Discord bot command prefix", Required = true)]
public string SocketHost { get; init; } = default!; public string SocketHost { get; init; } = default!;
[JsonProperty("admins", Required = Required.DisallowNull)]
[Option("admins", Default = new ulong[] { }, HelpText = "The list of bot administrators.")]
public ulong[] Administrators { get; init; } = Array.Empty<ulong>();
[JsonProperty("logchannel", Required = Required.DisallowNull)]
[Option("logchannel", Default = null, HelpText = "Optionally the id of a channel to mirror log to.")]
public ulong? LogChannel { get; init; } = null;
[JsonIgnore] [JsonIgnore]
public BotConfiguration Config => this; public BotConfiguration Config => this;
} }

View File

@ -1,5 +1,4 @@
using MinecraftDiscordBot.Models; using MinecraftDiscordBot.Services;
using MinecraftDiscordBot.Services;
namespace MinecraftDiscordBot; namespace MinecraftDiscordBot;
@ -18,7 +17,7 @@ public class ChunkWaiter<T> : IChunkWaiter {
public bool IsCancellationRequested => _ct.IsCancellationRequested; public bool IsCancellationRequested => _ct.IsCancellationRequested;
private string?[]? _chunks = null; private string?[]? _chunks = null;
private int _receivedChunks = 0; private int _receivedChunks = 0;
private ResultState? _state = null; private bool _success = true;
private readonly object _syncRoot = new(); private readonly object _syncRoot = new();
public void AddChunk(int chunkId, int totalChunks, string value) { public void AddChunk(int chunkId, int totalChunks, string value) {
lock (_syncRoot) { lock (_syncRoot) {
@ -38,15 +37,9 @@ public class ChunkWaiter<T> : IChunkWaiter {
} }
private void FinalizeResult(string?[] _chunks) { private void FinalizeResult(string?[] _chunks) {
var resultString = string.Concat(_chunks); var resultString = string.Concat(_chunks);
switch (_state) { if (_success) tcs.SetResult(resultParser(resultString));
case ResultState.Successful: tcs.SetResult(resultParser(resultString)); break; else tcs.SetException(new ReplyException(resultString));
case ResultState.Unsuccessful: tcs.SetException(new ReplyException(resultString)); break;
case ResultState.Fatal: tcs.SetException(new InvalidProgramException($"Client script failed: {resultString}")); break;
default: throw new InvalidProgramException($"Program cannot handle result state '{_state}'!");
}
Finished = true; Finished = true;
} }
public void SetResultState(ResultState state) => _state = _state is ResultState oldState && state != oldState public void SetUnsuccessful() => _success = false;
? throw new InvalidOperationException("Cannot set two different result states for same message!")
: state;
} }

View File

@ -1,25 +1,17 @@
local secretToken = "$TOKEN" local secretToken = "$TOKEN"
local connectionUri = "$HOST" local connectionUri = "$HOST"
local waitSeconds = 5 local waitSeconds = 5
-- https://github.com/cc-tweaked/CC-Tweaked/blob/9cf70b10effeeed23e0e9c537bbbe0b2ff0d1a0f/src/main/java/dan200/computercraft/core/apis/http/options/AddressRule.java#L29
-- Chunk size must be less than packet size (type reply, success, chunkids, content: chunk) 16 kb for buffer
local maxMessageSize = (128 - 16) * 1024
local function chunkString(value, chunkSize) local function chunkString(value, chunkSize)
if not chunkSize then chunkSize = maxMessageSize end if not chunkSize then chunkSize = 10000 end
local length = value:len() local length = value:len()
local total = math.ceil(length / chunkSize) local total = math.ceil(length / chunkSize)
local chunks = {} local chunks = {}
if length == 0 then
total = 1
chunks[1] = ""
else
local i = 1 local i = 1
for i=1,total do for i=1,total do
local pos = 1 + ((i - 1) * chunkSize) local pos = 1 + ((i - 1) * chunkSize)
chunks[i] = value:sub(pos, pos + chunkSize - 1) chunks[i] = value:sub(pos, pos + chunkSize - 1)
end end
end
return total, chunks return total, chunks
end end
@ -28,11 +20,16 @@ local function sendJson(socket, message)
end end
local function sendResponse(socket, id, result, success) local function sendResponse(socket, id, result, success)
if success == nil then success = 0 end if success == nil then success = true end
if not success then
sendJson(socket, { id = id, result = result, success = success })
return
end
local total, chunks = chunkString(result) local total, chunks = chunkString(result)
for i, chunk in pairs(chunks) do for i, chunk in pairs(chunks) do
sendJson(socket, { type = "reply", id = id, result = chunk, chunk = i, total = total, success = success }) sendJson(socket, { id = id, result = chunk, chunk = i, total = total, success = success })
end end
end end
@ -40,33 +37,10 @@ end
-- return rssystem rs -- return rssystem rs
local function getPeripheral(name) local function getPeripheral(name)
local dev = peripheral.find(name) local dev = peripheral.find(name)
if not dev then error({message = "No peripheral '"..name.."' attached to the computer!"}) end if not dev then error("No peripheral '"..name.."' attached to the computer!") end
return dev return dev
end end
local function runRsCommand(params)
local script, reason = loadstring("local rs = peripheral.find(\"rsBridge\") if not rs then error({message = \"RS Bridge is not attached!\"}) end return rs."..params.command)
if not script then error({message = "Invalid command: "..reason.."!"}) end
local result = table.pack(pcall(script))
local success = result[1]
if not success then error({message = "Command execution failed: "..result[2].."!"}) end
local retvals = {}
retvals.n = result.n - 1
for i=1,retvals.n do retvals[tostring(i)] = result[i + 1] end
return textutils.serializeJSON(retvals)
end
local function getPeripheralInfo(side)
return {type = peripheral.getType(side), methods = peripheral.getMethods(side), side = side}
end
local function getPeripheralList()
local pers = {}
for i,side in pairs(peripheral.getNames()) do pers[side] = getPeripheralInfo(side) end
return pers
end
-- error: any error during execution -- error: any error during execution
-- return string result -- return string result
local function getResponse(parsed) local function getResponse(parsed)
@ -81,29 +55,10 @@ local function getResponse(parsed)
elseif parsed.method == "craft" then elseif parsed.method == "craft" then
return tostring(getPeripheral("rsBridge").craftItem(parsed.params)) return tostring(getPeripheral("rsBridge").craftItem(parsed.params))
elseif parsed.method == "getitem" then elseif parsed.method == "getitem" then
local item = getPeripheral("rsBridge").getItem(parsed.params) return textutils.serializeJSON(getPeripheral("rsBridge").getItem(parsed.params))
if not item then error({message = "Requested item not found!"}) end
return textutils.serializeJSON(item)
elseif parsed.method == "command" then
return runRsCommand(parsed.params)
elseif parsed.method == "peripherals" then
return textutils.serializeJSON(getPeripheralList())
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)
elseif parsed.method == "send" then
if not parsed.params.username then
getPeripheral("chatBox").sendMessage(parsed.params.message, parsed.params.prefix)
else
getPeripheral("chatBox").sendMessageToPlayer(parsed.params.message, parsed.params.username, parsed.params.prefix)
end
return "true"
end end
error({message = "No message handler for method: "..parsed.method.."!"}) error("No message handler for method: "..parsed.method.."!")
end end
local function logJSON(json, prefix) local function logJSON(json, prefix)
@ -131,15 +86,7 @@ local function handleMessage(socket, message)
if parsed.type == "request" then if parsed.type == "request" then
local success, result = pcall(function() return getResponse(parsed) end) local success, result = pcall(function() return getResponse(parsed) end)
if not success then sendResponse(socket, parsed.id, result, success)
if not result.message then
sendResponse(socket, parsed.id, result, 2)
else
sendResponse(socket, parsed.id, result.message, 1)
end
else
sendResponse(socket, parsed.id, result, 0)
end
return true return true
end end
@ -147,7 +94,13 @@ local function handleMessage(socket, message)
return false return false
end end
local function responder(socket) 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)
while true do while true do
local message, binary = socket.receive() local message, binary = socket.receive()
if not not message and not binary then if not not message and not binary then
@ -164,97 +117,15 @@ local function termWaiter()
os.pullEvent("terminate") os.pullEvent("terminate")
end end
local function chatEventListener(socket) local function services()
while true do parallel.waitForAny(termWaiter, function()
event, username, message, uuid, hidden = os.pullEvent("chat") parallel.waitForAll(socketClient)
sendJson(socket, {type = "chat", username = username, message = message, uuid = uuid, hidden = hidden}) end)
print("Chat event relayed!")
end
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 listAsSet(list)
local asSet = {}
for i,elem in pairs(list) do asSet[elem] = true end
return asSet
end
local function joinSets(a, b)
local joined = {}
for elem,exists in pairs(a) do if exists then joined[elem] = true end end
for elem,exists in pairs(b) do if exists then joined[elem] = true end end
return joined
end
local function playerStatusEventListener(socket)
local players = {}
while true do
local pd = peripheral.find("playerDetector")
if not not pd then
players = listAsSet(pd.getOnlinePlayers())
break
end
printError("playerDetector not connected!")
end
while true do
local pd = peripheral.find("playerDetector")
if not not pd then
local newPlayers = listAsSet(pd.getOnlinePlayers())
for player,_ in pairs(joinSets(players, newPlayers)) do
if players[player] and (not newPlayers[player]) then
sendJson(socket, {type = "playerstatus", player = player, status = false})
elseif (not players[player]) and newPlayers[player] then
sendJson(socket, {type = "playerstatus", player = player, status = true})
end
end
players = newPlayers
end
sleep(1)
end
end
local function eventListeners(socket)
parallel.waitForAny(
termWaiter,
function() chatEventListener(socket) end,
function() playerStatusEventListener(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 end
local function main() local function main()
while true do while true do
local status, error = pcall(socketClient) local status, error = pcall(services)
if status then break end if status then break end
printError("An uncaught exception was raised:", error) printError("An uncaught exception was raised:", error)
printError("Restarting in", waitSeconds, "seconds...") printError("Restarting in", waitSeconds, "seconds...")

View File

@ -1,5 +1,4 @@
using Discord.WebSocket; using Discord.WebSocket;
using MinecraftDiscordBot.Models;
using MinecraftDiscordBot.Services; using MinecraftDiscordBot.Services;
using System.Reflection; using System.Reflection;
using System.Text; using System.Text;
@ -9,7 +8,6 @@ namespace MinecraftDiscordBot.Commands;
public abstract class CommandRouter : ICommandHandler<ResponseType> { public abstract class CommandRouter : ICommandHandler<ResponseType> {
public record struct HandlerStruct(HandleCommandDelegate<ResponseType> Delegate, CommandHandlerAttribute Attribute); public record struct HandlerStruct(HandleCommandDelegate<ResponseType> Delegate, CommandHandlerAttribute Attribute);
private readonly Dictionary<string, HandlerStruct> _handlers = new(); private readonly Dictionary<string, HandlerStruct> _handlers = new();
public abstract string HelpTextPrefix { get; } public abstract string HelpTextPrefix { get; }
public CommandRouter() { public CommandRouter() {
foreach (var method in GetType().GetMethods()) foreach (var method in GetType().GetMethods())
@ -27,7 +25,7 @@ public abstract class CommandRouter : ICommandHandler<ResponseType> {
=> Task.FromResult(ResponseType.AsString(GenerateHelp())); => Task.FromResult(ResponseType.AsString(GenerateHelp()));
private static CommandHandlerAttribute? GetHandlerAttribute(MethodInfo method) private static CommandHandlerAttribute? GetHandlerAttribute(MethodInfo method)
=> method.GetCustomAttributes(typeof(CommandHandlerAttribute), true).OfType<CommandHandlerAttribute>().FirstOrDefault(); => method.GetCustomAttributes(typeof(CommandHandlerAttribute), true).OfType<CommandHandlerAttribute>().FirstOrDefault();
public virtual Task<ResponseType> RootAnswer(SocketUserMessage message, CancellationToken ct) => GetHelpText(message, Array.Empty<string>(), ct); public abstract Task<ResponseType> RootAnswer(SocketUserMessage message, CancellationToken ct);
public abstract Task<ResponseType> FallbackHandler(SocketUserMessage message, string method, string[] parameters, CancellationToken ct); public abstract Task<ResponseType> FallbackHandler(SocketUserMessage message, string method, string[] parameters, CancellationToken ct);
public Task<ResponseType> HandleCommand(SocketUserMessage message, string[] parameters, CancellationToken ct) public Task<ResponseType> HandleCommand(SocketUserMessage message, string[] parameters, CancellationToken ct)
=> parameters is { Length: 0 } => parameters is { Length: 0 }

View File

@ -1,11 +1,9 @@
using MinecraftDiscordBot.Models; namespace MinecraftDiscordBot;
namespace MinecraftDiscordBot;
public interface IChunkWaiter { public interface IChunkWaiter {
bool Finished { get; } bool Finished { get; }
int ID { get; } int ID { get; }
bool IsCancellationRequested { get; } bool IsCancellationRequested { get; }
void AddChunk(int chunkId, int totalChunks, string value); void AddChunk(int chunkId, int totalChunks, string value);
void SetResultState(ResultState state); void SetUnsuccessful();
} }

View File

@ -1,13 +0,0 @@
using Discord.WebSocket;
namespace MinecraftDiscordBot;
public interface IUserRoleManager {
/// <summary>
/// Verifies that a user is a bot administrator.
/// </summary>
/// <param name="user">User ID.</param>
/// <param name="message">An optional message to throw when user is not authorized.</param>
/// <exception cref="ReplyException">User is not authorized.</exception>
void RequireAdministrator(ulong user, string? message = null);
}

View File

@ -6,7 +6,7 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<Version>1.1.3</Version> <Version>1.0.2</Version>
<Authors>Michael Chen</Authors> <Authors>Michael Chen</Authors>
<Company>$(Authors)</Company> <Company>$(Authors)</Company>
<RepositoryUrl>https://gitlab.com/chenmichael/mcdiscordbot</RepositoryUrl> <RepositoryUrl>https://gitlab.com/chenmichael/mcdiscordbot</RepositoryUrl>

View File

@ -19,6 +19,4 @@ public class Fluid {
: $"{Amount:n0} mB of {DisplayName}"; : $"{Amount:n0} mB of {DisplayName}";
[JsonIgnore] [JsonIgnore]
public string CleanDisplayName => DisplayName[1..^1]; public string CleanDisplayName => DisplayName[1..^1];
[JsonIgnore]
public string? TagString => Tags is string[] tags ? string.Join(", ", tags) : null;
} }

View File

@ -1,6 +1,5 @@
using Newtonsoft.Json; using Newtonsoft.Json;
using System.Diagnostics; using System.Diagnostics;
using System.Text;
namespace MinecraftDiscordBot.Models; namespace MinecraftDiscordBot.Models;
@ -11,23 +10,5 @@ public class Item : Fluid {
public Md5Hash Fingerprint { get; set; } = default!; public Md5Hash Fingerprint { get; set; } = default!;
[JsonProperty("nbt", Required = Required.DisallowNull)] [JsonProperty("nbt", Required = Required.DisallowNull)]
public dynamic? NBT { get; set; } public dynamic? NBT { get; set; }
public override string ToString() => $"{AmountString} {CleanDisplayName}"; public override string ToString() => $"{Amount:n0}x {DisplayName}";
[JsonIgnore]
public string DetailString {
get {
var sb = new StringBuilder();
sb.AppendFormat("{0} {1}, fp: {2}", AmountString, CleanDisplayName, Fingerprint);
if (TagString is string tags)
sb.AppendFormat(", tags: [{0}]", tags);
if (NBT is not null)
sb.AppendFormat(", NBT: {0}", JsonConvert.SerializeObject(NBT));
return sb.ToString();
}
}
[JsonIgnore]
public string AmountString => Amount switch {
> 1000000 => $"> {Amount / 1000000:n0}m",
> 10000 => $"~ {Amount / 1000.0f:n2}k",
_ => Amount.ToString()
};
} }

View File

@ -1,21 +0,0 @@
using Newtonsoft.Json;
namespace MinecraftDiscordBot.Models;
public class LuaPackedArray {
public ref object? this[int i] => ref _items[i];
private readonly object?[] _items;
public LuaPackedArray(IDictionary<string, object> packedTable) {
if (packedTable["n"] is not long n) throw new ArgumentException("No length in packed array!");
_items = new object?[n];
for (var i = 0; i < _items.Length; i++)
_items[i] = packedTable.TryGetValue((i + 1).ToString(), out var val) ? val : null;
}
public static LuaPackedArray Deserialize(string value) {
var dict = JsonConvert.DeserializeObject<Dictionary<string, object>>(value);
return new LuaPackedArray(dict ?? throw new Exception("Not a packed table (empty object)!"));
}
public override string ToString() => _items is { Length: 0 }
? "Empty Array"
: string.Join(", ", _items.Select(i => i is null ? "nil" : i.ToString()));
}

View File

@ -1,6 +1,5 @@
using Newtonsoft.Json; using Newtonsoft.Json;
using System.Diagnostics; using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
namespace MinecraftDiscordBot.Models; namespace MinecraftDiscordBot.Models;
@ -20,7 +19,7 @@ public class Md5Hash : IEquatable<Md5Hash?> {
hashCode.AddBytes(_hash); hashCode.AddBytes(_hash);
return hashCode.ToHashCode(); return hashCode.ToHashCode();
} }
public override string ToString() => Convert.ToHexString(_hash).ToLower(); public override string ToString() => Convert.ToHexString(_hash);
public class Md5JsonConverter : JsonConverter<Md5Hash> { public class Md5JsonConverter : JsonConverter<Md5Hash> {
public override Md5Hash? ReadJson(JsonReader reader, Type objectType, Md5Hash? existingValue, bool hasExistingValue, JsonSerializer serializer) public override Md5Hash? ReadJson(JsonReader reader, Type objectType, Md5Hash? existingValue, bool hasExistingValue, JsonSerializer serializer)
@ -32,14 +31,4 @@ public class Md5Hash : IEquatable<Md5Hash?> {
else writer.WriteValue(value.ToString()); else writer.WriteValue(value.ToString());
} }
} }
public static bool TryParse(string itemid, [NotNullWhen(true)] out Md5Hash? fingerprint) {
try {
fingerprint = new Md5Hash(itemid);
return true;
} catch (Exception) {
fingerprint = null;
return false;
}
}
} }

View File

@ -1,139 +1,54 @@
using Discord.WebSocket; using Discord.WebSocket;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Diagnostics;
namespace MinecraftDiscordBot.Models; namespace MinecraftDiscordBot.Models;
public abstract class Message { public abstract class Message {
public static Message Deserialize(string strMessage) {
var obj = JObject.Parse(strMessage);
var typeName = GetKey<string>(obj, "type");
if (!Parsers.TryGetValue(typeName, out var type))
throw new FormatException($"Unknown message type '{typeName}'!");
if (obj.ToObject(type) is not Message message)
throw new FormatException($"Message cannot be casted to '{type}'!");
return message;
}
private static readonly Dictionary<string, Type> Parsers = GetMessageTypes();
private static Dictionary<string, Type> GetMessageTypes() {
var types = new Dictionary<string, Type>();
var messageTypes =
AppDomain.CurrentDomain.GetAssemblies().SelectMany(domainAssembly => domainAssembly.GetTypes())
.Where(typeof(Message).IsAssignableFrom);
foreach (var type in messageTypes)
if (GetTypeAttribute(type) is MessageTypeAttribute attr)
types.Add(attr.Name, type);
return types;
}
private static MessageTypeAttribute? GetTypeAttribute(Type type)
=> type.GetCustomAttributes(typeof(MessageTypeAttribute), false).OfType<MessageTypeAttribute>().FirstOrDefault();
private static T GetKey<T>(JObject msg, string key)
=> (msg.TryGetValue(key, out var type) ? type : throw new FormatException($"Message has no '{key}' param!"))
.ToObject<T>() ?? throw new FormatException($"'{key}' param is not of expected type '{typeof(T).Name}'!");
[JsonProperty("type")] [JsonProperty("type")]
public abstract string Type { get; } public abstract string Type { get; }
} }
[MessageType(TYPE)]
[DebuggerDisplay($"{{{nameof(ToString)}(),nq}}")]
public class CapabilityMessage : Message { public class CapabilityMessage : Message {
private const string TYPE = "roles"; public override string Type => "roles";
public override string Type => TYPE;
[JsonProperty("role", Required = Required.Always)] [JsonProperty("role", Required = Required.Always)]
public string[] Role { get; set; } = default!; public string[] Role { get; set; } = default!;
public override string ToString() => $"Capabilities: {string.Join(", ", Role)}";
} }
[MessageType(TYPE)] public class TextMessage : Message {
[DebuggerDisplay($"{{{nameof(ToString)}(),nq}}")] public TextMessage(SocketMessage arg) : this(arg.Author.Username, arg.Content) { }
public TextMessage(string author, string content) {
Author = author;
Content = content;
}
public override string Type => "text";
[JsonProperty("author", Required = Required.Always)]
public string Author { get; set; }
[JsonProperty("message", Required = Required.Always)]
public string Content { get; set; }
}
public class ReplyMessage : Message { public class ReplyMessage : Message {
private const string TYPE = "reply"; public ReplyMessage(int answerId, string result) {
public override string Type => TYPE; AnswerId = answerId;
Result = result;
}
[JsonProperty("id", Required = Required.Always)] [JsonProperty("id", Required = Required.Always)]
public int AnswerId { get; set; } public int AnswerId { get; set; }
[JsonProperty("result", Required = Required.Always)] [JsonProperty("result", Required = Required.Always)]
public string Result { get; set; } = default!; public string Result { get; set; }
[JsonProperty("chunk", Required = Required.DisallowNull)] [JsonProperty("chunk", Required = Required.DisallowNull)]
public int Chunk { get; set; } = 1; public int Chunk { get; set; } = 1;
[JsonProperty("total", Required = Required.DisallowNull)] [JsonProperty("total", Required = Required.DisallowNull)]
public int Total { get; set; } = 1; public int Total { get; set; } = 1;
/// <summary>
/// If at least one packet was received where
/// </summary>
[JsonProperty("success", Required = Required.DisallowNull)] [JsonProperty("success", Required = Required.DisallowNull)]
public ResultState State { get; set; } = ResultState.Successful; public bool Success { get; set; } = true;
public override string ToString() => $"Reply [{AnswerId}] {State} ({Chunk}/{Total}) Length {Result.Length}"; public override string Type => "reply";
} }
public abstract class EventMessage : Message { }
[MessageType(TYPE)]
[DebuggerDisplay($"{{{nameof(ToString)}(),nq}}")]
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!;
public override string ToString() => $"Detached '{Side}'!";
}
[MessageType(TYPE)]
[DebuggerDisplay($"{{{nameof(ToString)}(),nq}}")]
public class PlayerStatusEvent : EventMessage {
private const string TYPE = "playerstatus";
public override string Type => TYPE;
[JsonProperty("player", Required = Required.Always)]
public string Player { get; set; } = default!;
[JsonProperty("status", Required = Required.Always)]
public bool Online { get; set; }
public override string ToString() => $"{Player} is now {(Online ? "on" : "off")}line!";
}
[MessageType(TYPE)]
[DebuggerDisplay($"{{{nameof(ToString)}(),nq}}")]
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 override string ToString() => $"Attached {Peripheral}!";
}
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!;
public override string ToString() => $"{Type} at '{Side}'";
}
[MessageType(TYPE)]
[DebuggerDisplay($"{{{nameof(ToString)}(),nq}}")]
public class ChatEvent : EventMessage {
private const string TYPE = "chat";
public override string Type => TYPE;
[JsonProperty("username", Required = Required.Always)]
public string Username { get; set; } = default!;
[JsonProperty("message", Required = Required.Always)]
public string Message { get; set; } = default!;
[JsonProperty("uuid", Required = Required.Always)]
public string UUID { get; set; } = default!;
[JsonProperty("hidden", Required = Required.Always)]
public bool IsHidden { get; set; }
public override string ToString() => $"{(IsHidden ? "HIDDEN: " : string.Empty)}[{Username}] {Message} ({UUID})";
}
[MessageType(TYPE)]
[DebuggerDisplay($"{{{nameof(ToString)}(),nq}}")]
public class RequestMessage : Message { public class RequestMessage : Message {
private const string TYPE = "request";
public override string Type => TYPE;
public RequestMessage(int answerId, string method, Dictionary<string, object>? parameters = null) { public RequestMessage(int answerId, string method, Dictionary<string, object>? parameters = null) {
AnswerId = answerId; AnswerId = answerId;
Method = method; Method = method;
@ -146,5 +61,5 @@ public class RequestMessage : Message {
public string Method { get; set; } public string Method { get; set; }
[JsonProperty("params")] [JsonProperty("params")]
public Dictionary<string, object> Parameters { get; } public Dictionary<string, object> Parameters { get; }
public override string ToString() => $"Request [{AnswerId}] {Method}({JsonConvert.SerializeObject(Parameters)})"; public override string Type => "request";
} }

View File

@ -1,7 +0,0 @@
namespace MinecraftDiscordBot.Models;
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public sealed class MessageTypeAttribute : Attribute {
public MessageTypeAttribute(string type) => Name = type;
public string Name { get; }
}

View File

@ -1,13 +0,0 @@
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

@ -1,7 +0,0 @@
namespace MinecraftDiscordBot.Models;
public enum ResultState {
Successful,
Unsuccessful,
Fatal
}

View File

@ -2,11 +2,9 @@
using Discord; using Discord;
using Discord.Commands; using Discord.Commands;
using Discord.Rest; using Discord.Rest;
using Discord.Webhook;
using Discord.WebSocket; using Discord.WebSocket;
using Fleck; using Fleck;
using MinecraftDiscordBot.Commands; using MinecraftDiscordBot.Commands;
using MinecraftDiscordBot.Models;
using MinecraftDiscordBot.Services; using MinecraftDiscordBot.Services;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Reflection; using System.Reflection;
@ -14,7 +12,7 @@ using System.Runtime.CompilerServices;
namespace MinecraftDiscordBot; namespace MinecraftDiscordBot;
public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleManager { public class Program : IDisposable, ICommandHandler<ResponseType> {
public const string WebSocketSource = "WebSocket"; public const string WebSocketSource = "WebSocket";
public const string BotSource = "Bot"; public const string BotSource = "Bot";
private static readonly object LogLock = new(); private static readonly object LogLock = new();
@ -28,16 +26,11 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
private readonly HashSet<ulong> _whitelistedChannels; private readonly HashSet<ulong> _whitelistedChannels;
private readonly ConcurrentDictionary<Guid, RootCommandService> _connections = new(); private readonly ConcurrentDictionary<Guid, RootCommandService> _connections = new();
private static readonly char[] WhiteSpace = new char[] { '\t', '\n', ' ', '\r' }; private static readonly char[] WhiteSpace = new char[] { '\t', '\n', ' ', '\r' };
public IEnumerable<ActiveChannel> Channels => _channels ?? throw new InvalidProgramException("Channels used before verification!"); public ITextChannel[] _channels = Array.Empty<ITextChannel>();
public ActiveChannel[]? _channels; private RootCommandService? _rsSystem = null;
private bool disposedValue; private bool disposedValue;
private static ITextChannel? LogChannel; public static bool OnlineNotifications => false;
private readonly RootCommandService _computer;
public static bool OnlineNotifications => true;
public const LogSeverity DiscordLogSeverity = LogSeverity.Warning;
private const string ClientScriptName = "MinecraftDiscordBot.ClientScript.lua"; private const string ClientScriptName = "MinecraftDiscordBot.ClientScript.lua";
private const string WebhookName = "minecraftbot";
public readonly string ClientScript; public readonly string ClientScript;
private readonly ITokenProvider _tokenProvider = new TimeoutTokenProvider(InstanceId, 10); private readonly ITokenProvider _tokenProvider = new TimeoutTokenProvider(InstanceId, 10);
private static readonly int InstanceId = new Random().Next(); private static readonly int InstanceId = new Random().Next();
@ -45,7 +38,7 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
private string GetVerifiedClientScript() => ClientScript private string GetVerifiedClientScript() => ClientScript
.Replace("$TOKEN", _tokenProvider.GenerateToken()); .Replace("$TOKEN", _tokenProvider.GenerateToken());
private static string GetClientScript(BotConfiguration config) { private string GetClientScript(BotConfiguration config) {
using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(ClientScriptName); using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(ClientScriptName);
if (stream is null) throw new FileNotFoundException("Client script could not be loaded!"); if (stream is null) throw new FileNotFoundException("Client script could not be loaded!");
using var sr = new StreamReader(stream); using var sr = new StreamReader(stream);
@ -53,20 +46,24 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
.Replace("$HOST", $"ws://{config.SocketHost}:{config.Port}"); .Replace("$HOST", $"ws://{config.SocketHost}:{config.Port}");
} }
private Task Broadcast(Func<ITextChannel, Task<IUserMessage>> message) public RootCommandService? Computer {
=> Task.WhenAll(Channels.Select(i => message(i.Channel))); get => _rsSystem; set {
if (_rsSystem != value) {
_rsSystem = value;
if (OnlineNotifications)
_ = Task.Run(() => Broadcast(i => i.SendMessageAsync(value is null
? $"The Refined Storage went offline. Please check the server!"
: $"The Refined Storage is back online!")));
}
}
}
private async Task Broadcast(Func<ITextChannel, Task<IUserMessage>> message) => _ = await Task.WhenAll(_channels.Select(message));
public Program(BotConfiguration config) { public Program(BotConfiguration config) {
_config = config; _config = config;
_computer = new(this);
_computer.ChatMessageReceived += MinecraftMessageReceived;
_computer.SocketChanged += ComputerConnectedChanged;
_computer.PlayerStatusChanged += PlayerStatusChanged;
_computer.PeripheralAttached += PeripheralAttached;
_computer.PeripheralDetached += PeripheralDetached;
_administrators = config.Administrators.ToHashSet();
ClientScript = GetClientScript(config); ClientScript = GetClientScript(config);
_client.Log += LogAsync; _client.Log += LogAsync;
_client.MessageReceived += (msg) => DiscordMessageReceived(msg, 20 * 1000); _client.MessageReceived += (msg) => DiscordMessageReceived(msg);
_client.ReactionAdded += DiscordReactionAdded; _client.ReactionAdded += DiscordReactionAdded;
_wssv = new WebSocketServer($"ws://0.0.0.0:{config.Port}") { _wssv = new WebSocketServer($"ws://0.0.0.0:{config.Port}") {
RestartAfterListenError = true RestartAfterListenError = true
@ -75,17 +72,6 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
_whitelistedChannels = config.Channels.ToHashSet(); _whitelistedChannels = config.Channels.ToHashSet();
} }
private void PlayerStatusChanged(object? sender, PlayerStatusEvent e)
=> _ = Task.Run(() => Broadcast(i => i.SendMessageAsync($"{e.Player} just {(e.Online ? "joined" : "left")} the server!")));
private void PeripheralAttached(object? sender, PeripheralAttachEvent e) => LogInfo("Computer", $"Peripheral {e.Peripheral.Type} was attached on side {e.Side}.");
private void PeripheralDetached(object? sender, PeripheralDetachEvent e) => LogInfo("Computer", $"Peripheral on side {e.Side} was detached.");
private void ComputerConnectedChanged(object? sender, IWebSocketConnection? e)
=> _ = Task.Run(() => Broadcast(i => i.SendMessageAsync(e is not null
? "The Minecraft client is now available!"
: "The Minecraft client disconnected!")));
private void MinecraftMessageReceived(object? sender, ChatEvent e)
=> Task.Run(() => WebhookBroadcast(i => i.SendMessageAsync(e.Message, username: e.Username, avatarUrl: $"https://crafatar.com/renders/head/{e.UUID}")));
private Task<T[]> WebhookBroadcast<T>(Func<DiscordWebhookClient, Task<T>> apply) => Task.WhenAll(Channels.Select(i => apply(new DiscordWebhookClient(i.Webhook))));
private void LogWebSocket(LogLevel level, string message, Exception exception) => Log(new(level switch { private void LogWebSocket(LogLevel level, string message, Exception exception) => Log(new(level switch {
LogLevel.Debug => LogSeverity.Debug, LogLevel.Debug => LogSeverity.Debug,
LogLevel.Info => LogSeverity.Info, LogLevel.Info => LogSeverity.Info,
@ -117,20 +103,11 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
} }
private async Task<bool> HasValidChannels() { private async Task<bool> HasValidChannels() {
if (_config.LogChannel is ulong logChannelId) { if (await GetValidChannels(_whitelistedChannels).ToArrayAsync() is not { Length: > 0 } channels) {
LogChannel = await IsValidChannel(logChannelId);
if (LogChannel is null)
await LogWarningAsync(BotSource, $"The given log channel ID is not valid '{logChannelId}'!");
}
if (await GetValidChannels(_whitelistedChannels) is not { Length: > 0 } channels) {
await LogErrorAsync(BotSource, new InvalidOperationException("No valid textchannel was whitelisted!")); await LogErrorAsync(BotSource, new InvalidOperationException("No valid textchannel was whitelisted!"));
return false; return false;
} }
_channels = await Task.WhenAll(channels.Select(async i => new ActiveChannel(i, await GetOrCreateWebhook(i)))); _channels = channels;
static async Task<IWebhook> GetOrCreateWebhook(ITextChannel i) {
var hooks = (await i.GetWebhooksAsync()).Where(i => i.Name == WebhookName).FirstOrDefault();
return hooks ?? await i.CreateWebhookAsync(WebhookName);
}
return true; return true;
} }
@ -140,14 +117,13 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
socket.OnMessage = async message => await SocketReceived(socket, message); socket.OnMessage = async message => await SocketReceived(socket, message);
}); });
private async Task<ITextChannel[]> GetValidChannels(IEnumerable<ulong> ids) private async IAsyncEnumerable<ITextChannel> GetValidChannels(IEnumerable<ulong> ids) {
=> (await Task.WhenAll(ids.Select(i => IsValidChannel(i)))).OfType<ITextChannel>().ToArray(); foreach (var channelId in ids) {
private async Task<ITextChannel?> IsValidChannel(ulong channelId) {
var channel = await _client.GetChannelAsync(channelId); var channel = await _client.GetChannelAsync(channelId);
if (channel is not ITextChannel textChannel) { if (channel is not ITextChannel textChannel) {
if (channel is null) await LogWarningAsync(BotSource, $"Channel with id [{channelId}] does not exist!"); if (channel is null) await LogWarningAsync(BotSource, $"Channel with id [{channelId}] does not exist!");
else await LogWarningAsync(BotSource, $"Channel is not a text channels and will not be used: {channel.Name} [{channel.Id}]!"); else await LogWarningAsync(BotSource, $"Channel is not a text channels and will not be used: {channel.Name} [{channel.Id}]!");
return null; continue;
} }
if (textChannel.Guild is RestGuild guild) { if (textChannel.Guild is RestGuild guild) {
@ -156,7 +132,8 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
} else { } else {
await LogWarningAsync(BotSource, $"Whitelisted in channel: {channel.Name} [{channel.Id}] on unknown server!"); await LogWarningAsync(BotSource, $"Whitelisted in channel: {channel.Name} [{channel.Id}] on unknown server!");
} }
return textChannel; yield return textChannel;
}
} }
private async Task SocketReceived(IWebSocketConnection socket, string message) { private async Task SocketReceived(IWebSocketConnection socket, string message) {
@ -164,21 +141,17 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
await (message switch { await (message switch {
"getcode" => SendClientCode(socket), "getcode" => SendClientCode(socket),
string s when s.StartsWith("login=") => ClientComputerConnected(socket, s[6..]), string s when s.StartsWith("login=") => ClientComputerConnected(socket, s[6..]),
string s when s.StartsWith("error=") => ClientComputerError(socket, s[6..]),
_ => DisruptClientConnection(socket, "Protocol violation!") _ => 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) { private async Task ClientComputerConnected(IWebSocketConnection socket, string token) {
if (!_tokenProvider.VerifyToken(token)) { if (!_tokenProvider.VerifyToken(token)) {
await DisruptClientConnection(socket, "outdated"); await DisruptClientConnection(socket, "outdated");
return; return;
} }
await LogInfoAsync(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Client logged in with valid script!"); await LogInfoAsync(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Client logged in with valid script!");
AddComputerSocket(socket); AddComputerSocket(socket, new(socket));
} }
private static async Task DisruptClientConnection(IWebSocketConnection socket, string reason) { private static async Task DisruptClientConnection(IWebSocketConnection socket, string reason) {
@ -192,10 +165,10 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
await LogInfoAsync(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Script sent to client!"); await LogInfoAsync(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Script sent to client!");
} }
private void AddComputerSocket(IWebSocketConnection socket) => _computer.Socket = socket; private void AddComputerSocket(IWebSocketConnection socket, RootCommandService pc) => Computer = pc;
private void RemoveComputerSocket(IWebSocketConnection socket) { private void RemoveComputerSocket(IWebSocketConnection socket) {
if (_computer.Socket is { ConnectionInfo.Id: Guid id } && id == socket.ConnectionInfo.Id) _computer.Socket = null; if (Computer is { ConnectionInfo.Id: Guid id } && id == socket.ConnectionInfo.Id) Computer = null;
} }
private async Task SocketClosed(IWebSocketConnection socket) { private async Task SocketClosed(IWebSocketConnection socket) {
@ -209,12 +182,10 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
if (arg is not SocketUserMessage message) return; if (arg is not SocketUserMessage message) return;
if (message.Author.IsBot) return; if (message.Author.IsBot) return;
if (!IsChannelWhitelisted(arg.Channel)) return; if (!IsChannelWhitelisted(arg.Channel)) return;
if (arg.Type is not MessageType.Default) return;
var cts = new CancellationTokenSource(timeout); var cts = new CancellationTokenSource(timeout);
if (IsCommand(message, out var argPos)) { if (IsCommand(message, out var argPos)) {
await arg.Channel.TriggerTypingAsync();
var parameters = message.Content[argPos..].Split(WhiteSpace, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var parameters = message.Content[argPos..].Split(WhiteSpace, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
_ = Task.Run(async () => { _ = Task.Run(async () => {
var response = await HandleCommand(message, parameters, cts.Token); var response = await HandleCommand(message, parameters, cts.Token);
@ -224,18 +195,16 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
} }
await LogInfoAsync("Discord", $"[{arg.Author.Username}] {arg.Content}"); await LogInfoAsync("Discord", $"[{arg.Author.Username}] {arg.Content}");
_ = Task.Run(() => _computer.Chat.SendMessageAsync(arg.Content, arg.Author.Username, cts.Token)); // TODO: Relay Message to Chat Receiver
} }
private Task SendResponse(SocketUserMessage message, ResponseType response) => response switch { private Task SendResponse(SocketUserMessage message, ResponseType response) => response switch {
ResponseType.IChoiceResponse res => HandleChoice(message, res), ResponseType.IChoiceResponse res => HandleChoice(message, res),
ResponseType.StringResponse res => message.ReplyAsync(res.Message), ResponseType.StringResponse res => message.ReplyAsync(res.Message),
ResponseType.FileResponse res => message.Channel.SendFileAsync(res.Path, text: res.Message), _ => message.ReplyAsync($"Whoops, someone forgot to implement '{response.GetType()}' responses?"),
_ => message.ReplyAsync($"Whoops, someone forgot to implement '{response.GetType().Name}' responses?"),
}; };
private readonly ConcurrentDictionary<ulong, ResponseType.IChoiceResponse> _choiceWait = new(); private readonly ConcurrentDictionary<ulong, ResponseType.IChoiceResponse> _choiceWait = new();
private readonly HashSet<ulong> _administrators;
private async Task DiscordReactionAdded(Cacheable<IUserMessage, ulong> message, Cacheable<IMessageChannel, ulong> channel, SocketReaction reaction) { private async Task DiscordReactionAdded(Cacheable<IUserMessage, ulong> message, Cacheable<IMessageChannel, ulong> channel, SocketReaction reaction) {
var msgObject = await message.GetOrDownloadAsync(); var msgObject = await message.GetOrDownloadAsync();
@ -259,7 +228,7 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
} }
public async Task<ResponseType> HandleCommand(SocketUserMessage message, string[] parameters, CancellationToken ct) { public async Task<ResponseType> HandleCommand(SocketUserMessage message, string[] parameters, CancellationToken ct) {
if (_computer is ICommandHandler<ResponseType> handler) if (Computer is ICommandHandler<ResponseType> handler)
try { try {
return await handler.HandleCommand(message, parameters, ct); return await handler.HandleCommand(message, parameters, ct);
} catch (TaskCanceledException) { } catch (TaskCanceledException) {
@ -289,29 +258,14 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
public static void LogError(string source, Exception exception) => Log(new(LogSeverity.Error, source, exception?.Message, exception)); public static void LogError(string source, Exception exception) => Log(new(LogSeverity.Error, source, exception?.Message, exception));
private static async Task LogAsync(LogMessage msg) { private static async Task LogAsync(LogMessage msg) {
lock (LogLock) { Log(msg);
var oldColor = Console.ForegroundColor; await Task.CompletedTask;
try {
Console.ForegroundColor = msg.Severity switch {
LogSeverity.Critical => ConsoleColor.Magenta,
LogSeverity.Error => ConsoleColor.Red,
LogSeverity.Warning => ConsoleColor.Yellow,
LogSeverity.Info => ConsoleColor.White,
LogSeverity.Verbose => ConsoleColor.Blue,
LogSeverity.Debug => ConsoleColor.DarkBlue,
_ => ConsoleColor.Cyan,
};
Console.WriteLine(msg.ToString());
} finally {
Console.ForegroundColor = oldColor;
}
}
if (msg.Severity <= DiscordLogSeverity && LogChannel is ITextChannel log) {
await log.SendMessageAsync($"{msg.Severity}: {msg}");
}
} }
public static void Log(LogMessage msg) => _ = Task.Run(() => LogAsync(msg)); public static void Log(LogMessage msg) {
lock (LogLock)
Console.WriteLine(msg.ToString());
}
protected virtual void Dispose(bool disposing) { protected virtual void Dispose(bool disposing) {
if (!disposedValue) { if (!disposedValue) {
@ -339,29 +293,12 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
Dispose(disposing: true); Dispose(disposing: true);
GC.SuppressFinalize(this); GC.SuppressFinalize(this);
} }
public void RequireAdministrator(ulong user, string? message = null) {
if (!_administrators.Contains(user))
throw new ReplyException(message ?? "User is not authorized to access this command!");
}
}
public class ActiveChannel {
public ActiveChannel(ITextChannel channel, IWebhook webhook) {
Channel = channel;
Webhook = webhook;
}
public IWebhook Webhook { get; }
public ITextChannel Channel { get; }
} }
public abstract class ResponseType { public abstract class ResponseType {
private static string DefaultDisplay<T>(T obj) => obj?.ToString() ?? throw new InvalidProgramException("ToString did not yield anything!"); private static string DefaultDisplay<T>(T obj) => obj?.ToString() ?? throw new InvalidProgramException("ToString did not yield anything!");
public static ResponseType AsString(string message) => new StringResponse(message); public static ResponseType AsString(string message) => new StringResponse(message);
public static ResponseType FromChoice<T>(string query, IEnumerable<T> choice, Func<T, Task> resultHandler, Func<T, string>? display = null) => new ChoiceResponse<T>(query, choice, resultHandler, display ?? DefaultDisplay); public static ResponseType FromChoice<T>(string query, IEnumerable<T> choice, Func<T, Task> resultHandler, Func<T, string>? display = null) => new ChoiceResponse<T>(query, choice, resultHandler, display ?? DefaultDisplay);
internal static ResponseType File(string path, string message) => new FileResponse(path, message);
public class StringResponse : ResponseType { public class StringResponse : ResponseType {
public StringResponse(string message) => Message = message; public StringResponse(string message) => Message = message;
public string Message { get; } public string Message { get; }
@ -385,14 +322,4 @@ public abstract class ResponseType {
_displayer = display; _displayer = display;
} }
} }
public class FileResponse : ResponseType {
public FileResponse(string path, string message) {
Path = path;
Message = message;
}
public string Path { get; }
public string Message { get; }
}
} }

View File

@ -1,26 +0,0 @@
using Discord.WebSocket;
using MinecraftDiscordBot.Commands;
namespace MinecraftDiscordBot.Services;
public class ChatBoxService : CommandRouter {
private readonly ITaskWaitSource _taskSource;
public ChatBoxService(ITaskWaitSource taskSource) => _taskSource = taskSource;
public override string HelpTextPrefix => "!chat ";
public override Task<ResponseType> FallbackHandler(SocketUserMessage message, string method, string[] parameters, CancellationToken ct)
=> throw new ReplyException($"The chat box 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<bool> SendMessageAsync(string message, string prefix, CancellationToken ct) => Method("send", RootCommandService.Deserialize<bool>(), ct, new() {
["message"] = message,
["prefix"] = prefix
});
public Task<bool> SendMessageToPlayerAsync(string message, string username, string prefix, CancellationToken ct) => Method("send", RootCommandService.Deserialize<bool>(), ct, new() {
["message"] = message,
["username"] = username,
["prefix"] = prefix
});
}

View File

@ -1,37 +0,0 @@
using Discord.WebSocket;
using MinecraftDiscordBot.Commands;
using MinecraftDiscordBot.Models;
using Newtonsoft.Json;
namespace MinecraftDiscordBot.Services;
public 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[]> GetOnlinePlayersAsync(CancellationToken ct) => Method("getonline", RootCommandService.Deserialize<string[]>(), ct);
public async Task<PlayerPosition> GetPlayerPosition(string username, CancellationToken ct)
=> (await FindPlayerAsync(username, ct)) ?? throw new ReplyException($"User '{username}' is not online!");
private Task<PlayerPosition?> FindPlayerAsync(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 GetOnlinePlayersAsync(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 FindPlayerAsync(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

@ -1,26 +1,24 @@
using Discord; using Discord.WebSocket;
using Discord.WebSocket;
using MinecraftDiscordBot.Commands; using MinecraftDiscordBot.Commands;
using MinecraftDiscordBot.Models; using MinecraftDiscordBot.Models;
using System.Text; using System.Text;
using System.Text.RegularExpressions;
namespace MinecraftDiscordBot.Services; namespace MinecraftDiscordBot.Services;
public class RefinedStorageService : CommandRouter { public class RefinedStorageService : CommandRouter {
private readonly ITaskWaitSource _taskSource; private readonly ITaskWaitSource _taskSource;
private readonly IUserRoleManager _roleManager;
public override string HelpTextPrefix => "!rs "; public override string HelpTextPrefix => "!rs ";
public RefinedStorageService(ITaskWaitSource taskSource, IUserRoleManager roleManager) : base() { public RefinedStorageService(ITaskWaitSource taskSource) : base() => _taskSource = taskSource;
_taskSource = taskSource;
_roleManager = roleManager;
}
public override Task<ResponseType> FallbackHandler(SocketUserMessage message, string method, string[] parameters, CancellationToken ct) public override Task<ResponseType> FallbackHandler(SocketUserMessage message, string method, string[] parameters, CancellationToken ct)
=> throw new ReplyException($"The RS system has no command '{method}'!"); => throw new ReplyException($"The RS system has no command '{method}'!");
public override Task<ResponseType> RootAnswer(SocketUserMessage message, CancellationToken ct)
=> Task.FromResult(ResponseType.AsString("The RS system is online!"));
private Task<T> Method<T>(string methodName, Func<string, T> parser, CancellationToken ct, Dictionary<string, object>? parameters = null) private async Task<T> Method<T>(string methodName, Func<string, T> parser, CancellationToken ct, Dictionary<string, object>? parameters = null) {
=> RootCommandService.Method(_taskSource, methodName, parser, ct, parameters); var waiter = _taskSource.GetWaiter(parser, ct);
await _taskSource.Send(new RequestMessage(waiter.ID, methodName, parameters));
return await waiter.Task;
}
private const string CmdEnergyUsage = "energyusage"; private const string CmdEnergyUsage = "energyusage";
private const string CmdEnergyStorage = "energystorage"; private const string CmdEnergyStorage = "energystorage";
@ -29,25 +27,18 @@ public class RefinedStorageService : CommandRouter {
private const string CmdListFluids = "listfluids"; private const string CmdListFluids = "listfluids";
private const string CmdCraftItem = "craft"; private const string CmdCraftItem = "craft";
private const string CmdGetItem = "getitem"; private const string CmdGetItem = "getitem";
private const string CmdCommand = "command";
public async Task<int> GetEnergyUsageAsync(CancellationToken ct) => await Method(CmdEnergyUsage, int.Parse, ct); public async Task<int> GetEnergyUsageAsync(CancellationToken ct) => await Method(CmdEnergyUsage, int.Parse, ct);
public async Task<int> GetEnergyStorageAsync(CancellationToken ct) => await Method(CmdEnergyStorage, int.Parse, ct); public async Task<int> GetEnergyStorageAsync(CancellationToken ct) => await Method(CmdEnergyStorage, int.Parse, ct);
public async Task<IEnumerable<Item>> ListItemsAsync(CancellationToken ct) => await Method(CmdListItems, RootCommandService.Deserialize<IEnumerable<Item>>(), ct); public async Task<IEnumerable<Item>> ListItemsAsync(CancellationToken ct) => await Method(CmdListItems, RootCommandService.Deserialize<IEnumerable<Item>>(), ct);
public async Task<IEnumerable<Fluid>> ListFluidsAsync(CancellationToken ct) => await Method(CmdListFluids, RootCommandService.Deserialize<IEnumerable<Fluid>>(), ct); public async Task<IEnumerable<Fluid>> ListFluidsAsync(CancellationToken ct) => await Method(CmdListFluids, RootCommandService.Deserialize<IEnumerable<Fluid>>(), ct);
public async Task<Item> GetItemDataAsync(string itemid, CancellationToken ct) => await Method(CmdGetItem, RootCommandService.Deserialize<Item>(), ct, new() { public async Task<Item> GetItemData(string itemid, CancellationToken ct) => await Method(CmdGetItem, RootCommandService.Deserialize<Item>(), ct, new() {
["name"] = itemid ["name"] = itemid
}); });
public async Task<Item> GetItemDataAsync(Md5Hash fingerprint, CancellationToken ct) => await Method(CmdGetItem, RootCommandService.Deserialize<Item>(), ct, new() { public async Task<bool> CraftItem(string itemid, int amount, CancellationToken ct) => await Method(CmdCraftItem, RootCommandService.Deserialize<bool>(), ct, new() {
["fingerprint"] = fingerprint.ToString()
});
public async Task<bool> CraftItemAsync(string itemid, int amount, CancellationToken ct) => await Method(CmdCraftItem, RootCommandService.Deserialize<bool>(), ct, new() {
["name"] = itemid, ["name"] = itemid,
["count"] = amount ["count"] = amount
}); });
public async Task<LuaPackedArray> RawCommandAsync(string command, CancellationToken ct) => await Method(CmdCommand, LuaPackedArray.Deserialize, ct, new() {
["command"] = command
});
private Task<IEnumerable<Item>> FilterItems(SocketUserMessage message, IEnumerable<string> filters, CancellationToken ct) private Task<IEnumerable<Item>> FilterItems(SocketUserMessage message, IEnumerable<string> filters, CancellationToken ct)
=> FilterItems(message, filters.Select(ItemFilter.Parse), ct); => FilterItems(message, filters.Select(ItemFilter.Parse), ct);
@ -92,18 +83,25 @@ public class RefinedStorageService : CommandRouter {
: parameters.Length is > 2 : parameters.Length is > 2
? throw new ReplyException("Yo, those are way too many arguments! I want only item name and maybe an amount!") ? throw new ReplyException("Yo, those are way too many arguments! I want only item name and maybe an amount!")
: throw new InvalidOperationException($"Forgot to match parameter length {parameters.Length}!"); : throw new InvalidOperationException($"Forgot to match parameter length {parameters.Length}!");
return await CraftItemAsync(itemid, amount, ct) return await CraftItem(itemid, amount, ct)
? ResponseType.AsString($"Alright, I'm starting to craft {amount} {itemid}.") ? ResponseType.AsString($"Alright, I'm starting to craft {amount} {itemid}.")
: ResponseType.AsString($"Nope, that somehow doesn't work!"); : ResponseType.AsString($"Nope, that somehow doesn't work!");
} }
[CommandHandler(CmdGetItem, HelpText = "Get information about a specific item.")] [CommandHandler(CmdGetItem, HelpText = "Get information about a specific item.")]
public async Task<ResponseType> HandleGetItemData(SocketUserMessage message, string[] parameters, CancellationToken ct) { public async Task<ResponseType> HandleGetItemData(SocketUserMessage message, string[] parameters, CancellationToken ct) {
var amount = 1;
string itemid; string itemid;
if (parameters.Length is not 1) throw new ReplyException($"I only want one name or fingerprint to search for, you gave me {parameters.Length} arguments!"); if (parameters.Length is 1 or 2) {
itemid = parameters[0]; itemid = parameters[0];
var item = await (Md5Hash.TryParse(itemid, out var fingerprint) if (parameters.Length is 2)
? GetItemDataAsync(fingerprint, ct) if (int.TryParse(parameters[1], out var value)) amount = value;
: GetItemDataAsync(itemid, ct)); else return ResponseType.AsString($"I expected an amount to craft, not '{parameters[1]}'!");
} else return parameters.Length is < 1
? ResponseType.AsString("You have to give me at least an item name!")
: parameters.Length is > 2
? ResponseType.AsString("Yo, those are way too many arguments! I want only item name and maybe an amount!")
: throw new InvalidOperationException($"Forgot to match parameter length {parameters.Length}!");
var item = await GetItemData(itemid, ct);
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.Append($"We currently have {item.Amount:n0} {item.CleanDisplayName}!"); sb.Append($"We currently have {item.Amount:n0} {item.CleanDisplayName}!");
if (item.Tags is not null and var tags) { if (item.Tags is not null and var tags) {
@ -113,7 +111,6 @@ public class RefinedStorageService : CommandRouter {
sb.Append($"\nRefer to this item with fingerprint {item.Fingerprint}"); sb.Append($"\nRefer to this item with fingerprint {item.Fingerprint}");
return ResponseType.AsString(sb.ToString()); return ResponseType.AsString(sb.ToString());
} }
[CommandHandler(CmdItemName, HelpText = "Filter items by name.")] [CommandHandler(CmdItemName, HelpText = "Filter items by name.")]
public async Task<ResponseType> HandleItemName(SocketUserMessage message, string[] parameters, CancellationToken ct) { public async Task<ResponseType> HandleItemName(SocketUserMessage message, string[] parameters, CancellationToken ct) {
if (parameters.Length < 2) return ResponseType.AsString($"Usage: {CmdItemName} filters..."); if (parameters.Length < 2) return ResponseType.AsString($"Usage: {CmdItemName} filters...");
@ -137,17 +134,8 @@ public class RefinedStorageService : CommandRouter {
return ResponseType.AsString(sb.ToString()); return ResponseType.AsString(sb.ToString());
} }
[CommandHandler(CmdCommand, HelpText = "Runs a raw command on the RS system.")]
public async Task<ResponseType> HandleRawCommand(SocketUserMessage message, string[] parameters, CancellationToken ct) {
_roleManager.RequireAdministrator(message.Author.Id, "You are not authorized to run raw commands on this instance!");
var command = string.Join(' ', parameters);
var response = await RawCommandAsync(command, ct);
return ResponseType.AsString(response.ToString());
}
[CommandHandler(CmdListItems, HelpText = "Gets a list of items that are currently stored in the RS system.")] [CommandHandler(CmdListItems, HelpText = "Gets a list of items that are currently stored in the RS system.")]
public async Task<ResponseType> HandleItemListing(SocketUserMessage message, string[] parameters, CancellationToken ct) { public async Task<ResponseType> HandleItemListing(SocketUserMessage message, string[] parameters, CancellationToken ct) {
if (parameters.Length is 1 && parameters[0] == "full") return await SendFullItemList(message, ct);
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.Append("The Refined Storage system currently stores these items:"); sb.Append("The Refined Storage system currently stores these items:");
var items = await RefreshItemList(ct); var items = await RefreshItemList(ct);
@ -155,29 +143,11 @@ public class RefinedStorageService : CommandRouter {
var taken = 0; var taken = 0;
foreach (var item in items) { foreach (var item in items) {
if (sb.Length > 500) break; if (sb.Length > 500) break;
sb.Append('\n'); sb.AppendFormat("\n{0:n0}x {1}", item.Amount, item.DisplayName);
sb.Append(item.ToString());
taken++; taken++;
} }
if (items.Count > taken) sb.AppendFormat("\nand {0:n0} more items.", items.Skip(taken).Sum(i => i.Amount)); if (items.Count > taken) sb.AppendFormat("\nand {0} more items.", items.Skip(taken).Sum(i => i.Amount));
} }
return ResponseType.AsString(sb.ToString()); return ResponseType.AsString(sb.ToString());
} }
private async Task<ResponseType> SendFullItemList(SocketUserMessage message, CancellationToken ct) {
var path = await GetItemListFile(ct);
return ResponseType.File(path, $"{message.Author.Mention} Here you go:");
}
private async Task<string> GetItemListFile(CancellationToken ct) {
var items = await RefreshItemList(ct);
var file = Path.Combine(Path.GetTempPath(), "itemlist.txt");
var fs = new FileStream(file, FileMode.OpenOrCreate, FileAccess.Write);
using (var sw = new StreamWriter(fs, Encoding.UTF8)) {
await sw.WriteLineAsync("The RS System stores the following items:");
foreach (var item in items)
await sw.WriteLineAsync(item.DetailString);
};
return file;
}
} }

View File

@ -3,7 +3,6 @@ using Fleck;
using MinecraftDiscordBot.Commands; using MinecraftDiscordBot.Commands;
using MinecraftDiscordBot.Models; using MinecraftDiscordBot.Models;
using Newtonsoft.Json; using Newtonsoft.Json;
using System.Linq;
namespace MinecraftDiscordBot.Services; namespace MinecraftDiscordBot.Services;
@ -11,77 +10,34 @@ public delegate Task<TResponse> HandleCommandDelegate<TResponse>(SocketUserMessa
public delegate Task HandleCommandDelegate(SocketUserMessage message, string[] parameters, CancellationToken ct); public delegate Task HandleCommandDelegate(SocketUserMessage message, string[] parameters, CancellationToken ct);
public class RootCommandService : CommandRouter, ITaskWaitSource { public class RootCommandService : CommandRouter, ITaskWaitSource {
protected IWebSocketConnection? _socketField; protected readonly IWebSocketConnection _socket;
public override string HelpTextPrefix => "!"; public override string HelpTextPrefix => "!";
public RootCommandService(IUserRoleManager roleManager) : base() { public RootCommandService(IWebSocketConnection socket) : base() {
RefinedStorage = new RefinedStorageService(this, roleManager); socket.OnMessage = OnMessage;
Players = new PlayerDetectorService(this); _socket = socket;
Chat = new ChatBoxService(this); _rs = new RefinedStorageService(this);
} }
public static async Task<T> Method<T>(ITaskWaitSource taskSource, 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;
}
public event EventHandler<ChatEvent>? ChatMessageReceived;
public event EventHandler<PlayerStatusEvent>? PlayerStatusChanged;
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);
}
}
}
public RefinedStorageService RefinedStorage { get; }
public PlayerDetectorService Players { get; }
public ChatBoxService Chat { get; }
private void OnMessage(string message) { private void OnMessage(string message) {
switch (Message.Deserialize(message)) { if (JsonConvert.DeserializeObject<ReplyMessage>(message) is not ReplyMessage msg) return;
case ChatEvent msg:
ChatMessageReceived?.Invoke(this, msg);
break;
case PlayerStatusEvent msg:
PlayerStatusChanged?.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; IChunkWaiter? waiter;
lock (_syncRoot) if (!_waits.TryGetValue(msg.AnswerId, out waiter)) { lock (_syncRoot) if (!_waits.TryGetValue(msg.AnswerId, out waiter)) {
Program.LogWarningAsync("Socket", $"Invalid wait id '{msg.AnswerId}'!"); Program.LogWarningAsync("Socket", $"Invalid wait id '{msg.AnswerId}'!");
return; return;
} }
waiter.SetResultState(msg.State); if (!msg.Success) waiter.SetUnsuccessful();
waiter.AddChunk(msg.Chunk, msg.Total, msg.Result); waiter.AddChunk(msg.Chunk, msg.Total, msg.Result);
if (waiter.Finished || waiter.IsCancellationRequested) if (waiter.Finished || waiter.IsCancellationRequested)
lock (_syncRoot) lock (_syncRoot)
_waits.Remove(waiter.ID); _waits.Remove(waiter.ID);
break;
default:
Program.LogInfo(Program.WebSocketSource, $"Received unhandled message: {message}!");
break;
}
} }
public Task Send(string message) => (Socket ?? throw new ReplyException("Minecraft server is not available!")).Send(message); public Task Send(string message) => _socket.Send(message);
public Task Send(Message message) => Send(JsonConvert.SerializeObject(message)); public Task Send(Message message) => Send(JsonConvert.SerializeObject(message));
private readonly object _syncRoot = new(); private readonly object _syncRoot = new();
private readonly Dictionary<int, IChunkWaiter> _waits = new(); private readonly Dictionary<int, IChunkWaiter> _waits = new();
private readonly Random _rnd = new(); private readonly Random _rnd = new();
public IWebSocketConnectionInfo ConnectionInfo => _socket.ConnectionInfo;
private int GetFreeId() { private int GetFreeId() {
var attempts = 0; var attempts = 0;
@ -102,22 +58,15 @@ public class RootCommandService : CommandRouter, ITaskWaitSource {
return waiter; return waiter;
} }
public Task<Dictionary<string, Peripheral>> GetPeripherals(CancellationToken ct) => Method(this, "peripherals", Deserialize<Dictionary<string, Peripheral>>(), ct); private readonly ICommandHandler<ResponseType> _rs;
[CommandHandler("rs", HelpText = "Provides some commands for interacting with the Refined Storage system.")] [CommandHandler("rs", HelpText = "Provides some commands for interacting with the Refined Storage system.")]
public Task<ResponseType> RefinedStorageHandler(SocketUserMessage message, string[] parameters, CancellationToken ct) public Task<ResponseType> RefinedStorageHandler(SocketUserMessage message, string[] parameters, CancellationToken ct)
=> RefinedStorage.HandleCommand(message, parameters, ct); => _rs.HandleCommand(message, parameters, ct);
[CommandHandler("peripherals", HelpText = "Gets a list of peripherals that are attached.")]
public async Task<ResponseType> HandleGetPeripherals(SocketUserMessage message, string[] parameters, CancellationToken ct)
=> ResponseType.AsString(string.Join("\n", (await GetPeripherals(ct)).Values.Select(i => $"On side {i.Side}: {i.Type}")));
[CommandHandler("pd", HelpText = "Provides some commands for interacting with the Player Detector.")]
public Task<ResponseType> PlayerDetectorHandler(SocketUserMessage message, string[] parameters, CancellationToken ct)
=> Players.HandleCommand(message, parameters, ct);
[CommandHandler("chat", HelpText = "Provides some commands for chatting.")]
public Task<ResponseType> ChatBoxHandler(SocketUserMessage message, string[] parameters, CancellationToken ct)
=> Chat.HandleCommand(message, parameters, ct);
public static Func<string, T> Deserialize<T>() => msg public static Func<string, T> Deserialize<T>() => msg
=> JsonConvert.DeserializeObject<T>(msg) ?? throw new InvalidProgramException("Empty response!"); => JsonConvert.DeserializeObject<T>(msg) ?? throw new InvalidProgramException("Empty response!");
public override Task<ResponseType> RootAnswer(SocketUserMessage message, CancellationToken ct)
=> Task.FromResult(ResponseType.AsString("The Minecraft server is connected!"));
public override Task<ResponseType> FallbackHandler(SocketUserMessage message, string method, string[] parameters, CancellationToken ct) public override Task<ResponseType> FallbackHandler(SocketUserMessage message, string method, string[] parameters, CancellationToken ct)
=> throw new ReplyException($"What the fuck do you mean by '{method}'?"); => throw new ReplyException($"What the fuck do you mean by '{method}'?");
} }