6 Commits

Author SHA1 Message Date
9fd50ee01e server:
Fixed cli help texts
Added administrator options for critical methods
Added result state for client and server specific errors
Redirect root to help text
Fixed fingerprint error, fingerprint must be case sensitive
Re-Added online messages
Added typing trigger for discord bot messages
client:
fixed chunkString for empty results preemtive
wrap error objects for server messages
both:
added raw lua RS Bridge command entry
2022-01-17 15:25:03 +01:00
4a98d4cb50 Bump version 2022-01-16 22:31:33 +01:00
fd3e6fdcc8 Cleanup, ordering and added host variable to client script 2022-01-16 22:29:50 +01:00
612435eb09 Added more item details in getitem 2022-01-16 22:15:11 +01:00
cd006fb268 Generate token with random prefix
Added getitem function for specific item
- that decrypt is not invoked for previous run of the server
- and that server restart always triggers a client update
2022-01-16 21:51:37 +01:00
0b9cb03bae Implemented auto updating lua script
Downloads latest script from server if outdated (10 seconds)
Server sends encrypted token to client to keep session new and rejects
..old tokens
This allows updating the script in this repository
2022-01-16 21:31:07 +01:00
23 changed files with 770 additions and 318 deletions

View File

@ -0,0 +1,44 @@
using System.Security.Cryptography;
namespace MinecraftDiscordBot;
public class AesCipher : ICipher {
private readonly byte[] key;
private readonly byte[] iv;
public AesCipher() {
using var aes = Aes.Create();
aes.GenerateKey();
aes.GenerateIV();
key = aes.Key;
iv = aes.IV;
}
public byte[] Encrypt(byte[] plain) {
using var aes = Aes.Create();
aes.Key = key;
aes.IV = iv;
var transformer = aes.CreateEncryptor();
using var ms = new MemoryStream();
using (var cs = new CryptoStream(ms, transformer, CryptoStreamMode.Write))
cs.Write(plain);
return ms.ToArray();
}
public byte[] Decrypt(byte[] cipher) {
using Aes aes = Aes.Create();
aes.Key = key;
aes.IV = iv;
var transformer = aes.CreateDecryptor();
using MemoryStream ms = new MemoryStream(cipher);
using CryptoStream cs = new CryptoStream(ms, transformer, CryptoStreamMode.Read);
using MemoryStream os = new MemoryStream();
cs.CopyTo(os);
return os.ToArray();
}
}
public interface ICipher {
byte[] Decrypt(byte[] cipher);
byte[] Encrypt(byte[] plain);
}

View File

@ -20,6 +20,12 @@ public class BotConfiguration : IBotConfiguration, IBotConfigurator {
[JsonProperty("prefix", Required = Required.DisallowNull)]
[Option("prefix", Default = DEFAULT_PREFIX, HelpText = "The Discord bot command prefix")]
public string Prefix { get; init; } = DEFAULT_PREFIX;
[JsonProperty("host", Required = Required.Always)]
[Option("host", Default = DEFAULT_PREFIX, HelpText = "The external websocket hostname.", Required = true)]
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>();
[JsonIgnore]
public BotConfiguration Config => this;
}

View File

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

View File

@ -0,0 +1,164 @@
local secretToken = "$TOKEN"
local connectionUri = "$HOST"
local waitSeconds = 5
local function chunkString(value, chunkSize)
if not chunkSize then chunkSize = 10000 end
local length = value:len()
local total = math.ceil(length / chunkSize)
local chunks = {}
if length == 0 then
total = 1
chunks[1] = ""
else
local i = 1
for i=1,total do
local pos = 1 + ((i - 1) * chunkSize)
chunks[i] = value:sub(pos, pos + chunkSize - 1)
end
end
return total, chunks
end
local function sendJson(socket, message)
return socket.send(textutils.serializeJSON(message))
end
local function sendResponse(socket, id, result, success)
if success == nil then success = 0 end
local total, chunks = chunkString(result)
for i, chunk in pairs(chunks) do
sendJson(socket, { id = id, result = chunk, chunk = i, total = total, success = success })
end
end
-- error: no rs system
-- return rssystem rs
local function getPeripheral(name)
local dev = peripheral.find(name)
if not dev then error({message = "No peripheral '"..name.."' attached to the computer!"}) end
return dev
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
-- error: any error during execution
-- return string result
local function getResponse(parsed)
if parsed.method == "energyusage" then
return tostring(getPeripheral("rsBridge").getEnergyUsage())
elseif parsed.method == "energystorage" then
return tostring(getPeripheral("rsBridge").getEnergyStorage())
elseif parsed.method == "listitems" then
return textutils.serializeJSON(getPeripheral("rsBridge").listItems())
elseif parsed.method == "listfluids" then
return textutils.serializeJSON(getPeripheral("rsBridge").listFluids())
elseif parsed.method == "craft" then
return tostring(getPeripheral("rsBridge").craftItem(parsed.params))
elseif parsed.method == "getitem" then
local item = 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)
end
error({message = "No message handler for method: "..parsed.method.."!"})
end
local function logJSON(json, prefix)
if not prefix then prefix = "" end
for k,v in pairs(json) do
local key = prefix..k
if type(v) == "table" then
logJSON(v, key..".")
else
print(key, "=", textutils.serializeJSON(v))
end
end
end
-- return bool success
local function handleMessage(socket, message)
local parsed, reason = textutils.unserializeJSON(message)
if not parsed then
print("Received message:", message)
printError("Message could not be parsed:", reason)
return false
end
pcall(function() print("Received JSON:") logJSON(parsed) end)
if parsed.type == "request" then
local success, result = pcall(function() return getResponse(parsed) end)
if not success then
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
end
printError("Invalid message type:", parsed.type)
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)
while true do
local message, binary = socket.receive()
if not not message and not binary then
if message == "outdated" then
printError("Current script is outdated! Please update from the host!")
return
end
handleMessage(socket, message)
end
end
end
local function termWaiter()
os.pullEvent("terminate")
end
local function services()
parallel.waitForAny(termWaiter, function()
parallel.waitForAll(socketClient)
end)
end
local function main()
while true do
local status, error = pcall(services)
if status then break end
printError("An uncaught exception was raised:", error)
printError("Restarting in", waitSeconds, "seconds...")
sleep(waitSeconds)
end
end
local oldPullEvent = os.pullEvent
os.pullEvent = os.pullEventRaw
pcall(main)
os.pullEvent = oldPullEvent

View File

@ -0,0 +1,8 @@
namespace MinecraftDiscordBot.Commands;
[AttributeUsage(AttributeTargets.Method, Inherited = true, AllowMultiple = false)]
public sealed class CommandHandlerAttribute : Attribute {
public CommandHandlerAttribute(string commandName) => CommandName = commandName;
public string CommandName { get; }
public string? HelpText { get; init; }
}

View File

@ -1,11 +1,12 @@
using Discord;
using Discord.WebSocket;
using Discord.WebSocket;
using MinecraftDiscordBot.Services;
using System.Reflection;
using System.Text;
namespace MinecraftDiscordBot;
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() {
@ -24,7 +25,7 @@ public abstract class CommandRouter : ICommandHandler<ResponseType> {
=> Task.FromResult(ResponseType.AsString(GenerateHelp()));
private static CommandHandlerAttribute? GetHandlerAttribute(MethodInfo method)
=> method.GetCustomAttributes(typeof(CommandHandlerAttribute), true).OfType<CommandHandlerAttribute>().FirstOrDefault();
public abstract Task<ResponseType> RootAnswer(SocketUserMessage message, CancellationToken ct);
public virtual Task<ResponseType> RootAnswer(SocketUserMessage message, CancellationToken ct) => GetHelpText(message, Array.Empty<string>(), ct);
public abstract Task<ResponseType> FallbackHandler(SocketUserMessage message, string method, string[] parameters, CancellationToken ct);
public Task<ResponseType> HandleCommand(SocketUserMessage message, string[] parameters, CancellationToken ct)
=> parameters is { Length: 0 }
@ -43,5 +44,3 @@ public abstract class CommandRouter : ICommandHandler<ResponseType> {
return sb.ToString();
}
}
public record struct HandlerStruct(HandleCommandDelegate<ResponseType> Delegate, CommandHandlerAttribute Attribute);

View File

@ -1,6 +1,6 @@
using Discord.WebSocket;
namespace MinecraftDiscordBot;
namespace MinecraftDiscordBot.Commands;
public interface ICommandHandler {
Task HandleCommand(SocketUserMessage message, string[] parameters, CancellationToken ct);

View File

@ -1,273 +0,0 @@
using Discord;
using Discord.WebSocket;
using Fleck;
using Newtonsoft.Json;
using System.Diagnostics;
using System.Runtime.Serialization;
using System.Text;
namespace MinecraftDiscordBot;
public delegate Task<TResponse> HandleCommandDelegate<TResponse>(SocketUserMessage message, string[] parameters, CancellationToken ct);
public delegate Task HandleCommandDelegate(SocketUserMessage message, string[] parameters, CancellationToken ct);
[AttributeUsage(AttributeTargets.Method, Inherited = true, AllowMultiple = false)]
public sealed class CommandHandlerAttribute : Attribute {
public CommandHandlerAttribute(string commandName) => CommandName = commandName;
public string CommandName { get; }
public string? HelpText { get; init; }
}
public class RefinedStorageService : CommandRouter {
private readonly ITaskWaitSource _taskSource;
public override string HelpTextPrefix => "!rs ";
public RefinedStorageService(ITaskWaitSource taskSource) : base() => _taskSource = taskSource;
public override Task<ResponseType> FallbackHandler(SocketUserMessage message, string method, string[] parameters, CancellationToken ct)
=> Task.FromResult(ResponseType.AsString($"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 async Task<T> Method<T>(string methodName, Func<string, T> parser, CancellationToken ct) {
var waiter = _taskSource.GetWaiter(parser, ct);
await _taskSource.Send(new RequestMessage(waiter.ID, methodName));
return await waiter.Task;
}
private const string CmdEnergyUsage = "energyusage";
private const string CmdEnergyStorage = "energystorage";
private const string CmdListItems = "listitems";
private const string CmdItemName = "itemname";
private const string CmdListFluids = "listfluids";
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<IEnumerable<Item>> ListItemsAsync(CancellationToken ct) => await Method(CmdListItems, ConnectedComputer.Deserialize<IEnumerable<Item>>(), ct);
public async Task<IEnumerable<Fluid>> ListFluidsAsync(CancellationToken ct) => await Method(CmdListFluids, ConnectedComputer.Deserialize<IEnumerable<Fluid>>(), ct);
private Task<IEnumerable<Item>> FilterItems(SocketUserMessage message, IEnumerable<string> filters, CancellationToken ct)
=> FilterItems(message, filters.Select(ItemFilter.Parse), ct);
private async Task<IEnumerable<Item>> FilterItems(SocketUserMessage message, IEnumerable<ItemFilter> filters, CancellationToken ct) {
var items = Items?.ToList().AsEnumerable();
if (items is null) items = (await RefreshItemList(ct)).ToList();
foreach (var filter in filters)
items = items.Where(filter.MatchItem);
return items.ToList();
}
private async Task<List<Item>> RefreshItemList(CancellationToken ct) {
var response = await ListItemsAsync(ct);
lock (_itemLock) {
Items = response.OrderByDescending(i => i.Amount).ToList();
return Items;
}
}
private List<Item>? Items;
private readonly object _itemLock = new();
[CommandHandler(CmdEnergyStorage, HelpText = "Get the amount of energy stored in the RS system.")]
public async Task<ResponseType> HandleEnergyStorage(SocketUserMessage message, string[] parameters, CancellationToken ct)
=> ResponseType.AsString($"Refined Storage system stores {await GetEnergyStorageAsync(ct)} RF/t");
[CommandHandler(CmdEnergyUsage, HelpText = "Get the amount of energy used by the RS system.")]
public async Task<ResponseType> HandleEnergyUsage(SocketUserMessage message, string[] parameters, CancellationToken ct)
=> ResponseType.AsString($"Refined Storage system currently uses {await GetEnergyUsageAsync(ct)} RF/t");
[CommandHandler(CmdItemName, HelpText = "Filter items by name.")]
public async Task<ResponseType> HandleItemName(SocketUserMessage message, string[] parameters, CancellationToken ct) {
if (parameters.Length < 2) return ResponseType.AsString($"Usage: {CmdItemName} filters...");
else {
var items = await FilterItems(message, parameters[1..], ct);
var sb = new StringBuilder();
sb.AppendLine("Did you mean:");
sb.AppendJoin("\n", items.Select(i => i.ToString()));
return ResponseType.AsString(sb.ToString());
}
}
[CommandHandler(CmdListFluids, HelpText = "Gets a list of fluids that are currently stored in the RS system.")]
public async Task<ResponseType> HandleFluidListing(SocketUserMessage message, string[] parameters, CancellationToken ct) {
var sb = new StringBuilder();
sb.Append("The Refined Storage system stores those fluids:");
var fluids = await ListFluidsAsync(ct);
foreach (var fluid in fluids.OrderByDescending(i => i.Amount))
if (fluid.Amount > 10000) sb.AppendFormat("\n{0:n2} B of {1}", fluid.Amount / 1000.0f, fluid.DisplayName);
else sb.AppendFormat("\n{0:n0} mB of {1}", fluid.Amount, fluid.DisplayName);
return ResponseType.AsString(sb.ToString());
}
[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) {
var sb = new StringBuilder();
sb.Append("The Refined Storage system currently stores these items:");
var items = await RefreshItemList(ct);
lock (_itemLock) {
int taken = 0;
foreach (var item in items) {
if (sb.Length > 500) break;
sb.AppendFormat("\n{0:n0}x {1}", item.Amount, item.DisplayName);
taken++;
}
if (items.Count > taken) sb.AppendFormat("\nand {0} more items.", items.Skip(taken).Sum(i => i.Amount));
}
return ResponseType.AsString(sb.ToString());
}
}
public class ConnectedComputer : CommandRouter, ITaskWaitSource {
protected readonly IWebSocketConnection _socket;
public override string HelpTextPrefix => "!";
public ConnectedComputer(IWebSocketConnection socket) : base() {
socket.OnMessage = OnMessage;
_socket = socket;
_rs = new RefinedStorageService(this);
}
private void OnMessage(string message) {
if (JsonConvert.DeserializeObject<ReplyMessage>(message) is not ReplyMessage msg) return;
IChunkWaiter? waiter;
lock (_syncRoot) {
if (!_waits.TryGetValue(msg.AnswerId, out waiter)) {
Program.LogWarningAsync("Socket", $"Invalid wait id '{msg.AnswerId}'!");
return;
}
}
if (!msg.Success) waiter.SetUnsuccessful();
waiter.AddChunk(msg.Chunk, msg.Total, msg.Result);
if (waiter.Finished || waiter.IsCancellationRequested)
lock (_syncRoot)
_waits.Remove(waiter.ID);
}
public Task Send(string message) => _socket.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;
while (true) {
var id = _rnd.Next();
if (!_waits.ContainsKey(id))
return id;
Program.LogWarningAsync(Program.WebSocketSource, $"Could not get a free ID after {++attempts} attempts!");
}
}
public ChunkWaiter<T> GetWaiter<T>(Func<string, T> resultParser, CancellationToken ct) {
ChunkWaiter<T> waiter;
lock (_syncRoot) {
waiter = new ChunkWaiter<T>(GetFreeId(), resultParser, ct);
_waits.Add(waiter.ID, waiter);
}
return waiter;
}
private readonly ICommandHandler<ResponseType> _rs;
[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);
public static Func<string, T> Deserialize<T>() => msg
=> 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)
=> Task.FromResult(ResponseType.AsString($"What the fuck do you mean by '{method}'?"));
}
public interface ITaskWaitSource {
ChunkWaiter<T> GetWaiter<T>(Func<string, T> resultParser, CancellationToken ct);
Task Send(Message requestMessage);
}
[Serializable]
public class ReplyException : Exception {
public ReplyException() { }
public ReplyException(string message) : base(message) { }
public ReplyException(string message, Exception inner) : base(message, inner) { }
protected ReplyException(SerializationInfo info, StreamingContext context) : base(info, context) { }
}
[JsonObject(MemberSerialization.OptIn, Description = "Describes an item in a Refined Storage system.", MissingMemberHandling = MissingMemberHandling.Ignore)]
[DebuggerDisplay($"{{{nameof(ToString)}(),nq}}")]
public class Item : Fluid {
[JsonProperty("fingerprint", Required = Required.Always)]
public Md5Hash Fingerprint { get; set; } = default!;
[JsonProperty("nbt", Required = Required.DisallowNull)]
public dynamic? NBT { get; set; }
public override string ToString() => $"{Amount:n0}x {DisplayName}";
}
[JsonObject(MemberSerialization.OptIn, Description = "Describes a fluid in a Refined Storage system.", MissingMemberHandling = MissingMemberHandling.Ignore)]
[DebuggerDisplay($"{{{nameof(ToString)}(),nq}}")]
public class Fluid {
[JsonProperty("amount", Required = Required.Always)]
public int Amount { get; set; }
[JsonProperty("displayName", Required = Required.Always)]
public string DisplayName { get; set; } = default!;
[JsonProperty("tags", Required = Required.DisallowNull)]
public string[]? Tags { get; set; } = default;
[JsonProperty("name", Required = Required.Always)]
public ModItemId ItemId { get; set; } = default!;
public override string ToString() => Amount > 10000
? $"{Amount / 1000.0f:n2} B of {DisplayName}"
: $"{Amount:n0} mB of {DisplayName}";
}
[JsonConverter(typeof(ModItemIdJsonConverter))]
[DebuggerDisplay($"{{{nameof(ToString)}(),nq}}")]
public class ModItemId {
public ModItemId(string name) {
var colon = name.IndexOf(':');
if (colon < 0) throw new ArgumentException("Invalid mod item id!", nameof(name));
ModName = name[..colon];
ModItem = name[(colon + 1)..];
if (ToString() != name) throw new InvalidProgramException("Bad Parsing!");
}
public override string ToString() => $"{ModName}:{ModItem}";
public string ModName { get; }
public string ModItem { get; }
public class ModItemIdJsonConverter : JsonConverter<ModItemId> {
public override ModItemId? ReadJson(JsonReader reader, Type objectType, ModItemId? existingValue, bool hasExistingValue, JsonSerializer serializer)
=> reader.Value is string value
? new(value)
: throw new JsonException($"Could not parse mod name with token '{reader.Value}'");
public override void WriteJson(JsonWriter writer, ModItemId? value, JsonSerializer serializer) {
if (value is null) writer.WriteNull();
else writer.WriteValue(value.ToString());
}
}
}
[JsonConverter(typeof(Md5JsonConverter))]
[DebuggerDisplay($"{{{nameof(ToString)}(),nq}}")]
public class Md5Hash : IEquatable<Md5Hash?> {
private readonly byte[] _hash;
public Md5Hash(string hash) : this(Convert.FromHexString(hash)) { }
public Md5Hash(byte[] hash) {
if (hash is not { Length: 16 }) throw new ArgumentException("Invalid digest size!", nameof(hash));
_hash = hash;
}
public override bool Equals(object? obj) => Equals(obj as Md5Hash);
public bool Equals(Md5Hash? other) => other != null && _hash.SequenceEqual(other._hash);
public override int GetHashCode() {
var hashCode = new HashCode();
hashCode.AddBytes(_hash);
return hashCode.ToHashCode();
}
public override string ToString() => Convert.ToHexString(_hash);
public class Md5JsonConverter : JsonConverter<Md5Hash> {
public override Md5Hash? ReadJson(JsonReader reader, Type objectType, Md5Hash? existingValue, bool hasExistingValue, JsonSerializer serializer)
=> reader.Value is string { Length: 32 } value
? new(value)
: throw new JsonException($"Could not parse MD5 hash with token '{reader.Value}'");
public override void WriteJson(JsonWriter writer, Md5Hash? value, JsonSerializer serializer) {
if (value is null) writer.WriteNull();
else writer.WriteValue(value.ToString());
}
}
}

View File

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

View File

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

@ -1,4 +1,6 @@
namespace MinecraftDiscordBot;
using MinecraftDiscordBot.Models;
namespace MinecraftDiscordBot;
public abstract class ItemFilter {
public abstract bool Match(Fluid item);

View File

@ -6,7 +6,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<Version>1.0.2</Version>
<Version>1.1.1</Version>
<Authors>Michael Chen</Authors>
<Company>$(Authors)</Company>
<RepositoryUrl>https://gitlab.com/chenmichael/mcdiscordbot</RepositoryUrl>
@ -15,13 +15,20 @@
<FileVersion>$(VersionPrefix)</FileVersion>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="ClientScript.lua" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.8.0" />
<PackageReference Include="Discord.Net" Version="3.1.0" />
<PackageReference Include="Fleck" Version="1.2.0" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.14.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="OneOf" Version="3.0.205" />
</ItemGroup>
<ItemGroup>
<Resource Include="ClientScript.lua" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,22 @@
using Newtonsoft.Json;
using System.Diagnostics;
namespace MinecraftDiscordBot.Models;
[JsonObject(MemberSerialization.OptIn, Description = "Describes a fluid in a Refined Storage system.", MissingMemberHandling = MissingMemberHandling.Ignore)]
[DebuggerDisplay($"{{{nameof(ToString)}(),nq}}")]
public class Fluid {
[JsonProperty("amount", Required = Required.Always)]
public int Amount { get; set; }
[JsonProperty("displayName", Required = Required.Always)]
public string DisplayName { get; set; } = default!;
[JsonProperty("tags", Required = Required.DisallowNull)]
public string[]? Tags { get; set; } = default;
[JsonProperty("name", Required = Required.Always)]
public ModItemId ItemId { get; set; } = default!;
public override string ToString() => Amount > 10000
? $"{Amount / 1000.0f:n2} B of {DisplayName}"
: $"{Amount:n0} mB of {DisplayName}";
[JsonIgnore]
public string CleanDisplayName => DisplayName[1..^1];
}

View File

@ -0,0 +1,14 @@
using Newtonsoft.Json;
using System.Diagnostics;
namespace MinecraftDiscordBot.Models;
[JsonObject(MemberSerialization.OptIn, Description = "Describes an item in a Refined Storage system.", MissingMemberHandling = MissingMemberHandling.Ignore)]
[DebuggerDisplay($"{{{nameof(ToString)}(),nq}}")]
public class Item : Fluid {
[JsonProperty("fingerprint", Required = Required.Always)]
public Md5Hash Fingerprint { get; set; } = default!;
[JsonProperty("nbt", Required = Required.DisallowNull)]
public dynamic? NBT { get; set; }
public override string ToString() => $"{Amount:n0}x {DisplayName}";
}

View File

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

@ -0,0 +1,45 @@
using Newtonsoft.Json;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
namespace MinecraftDiscordBot.Models;
[JsonConverter(typeof(Md5JsonConverter))]
[DebuggerDisplay($"{{{nameof(ToString)}(),nq}}")]
public class Md5Hash : IEquatable<Md5Hash?> {
private readonly byte[] _hash;
public Md5Hash(string hash) : this(Convert.FromHexString(hash)) { }
public Md5Hash(byte[] hash) {
if (hash is not { Length: 16 }) throw new ArgumentException("Invalid digest size!", nameof(hash));
_hash = hash;
}
public override bool Equals(object? obj) => Equals(obj as Md5Hash);
public bool Equals(Md5Hash? other) => other != null && _hash.SequenceEqual(other._hash);
public override int GetHashCode() {
var hashCode = new HashCode();
hashCode.AddBytes(_hash);
return hashCode.ToHashCode();
}
public override string ToString() => Convert.ToHexString(_hash).ToLower();
public class Md5JsonConverter : JsonConverter<Md5Hash> {
public override Md5Hash? ReadJson(JsonReader reader, Type objectType, Md5Hash? existingValue, bool hasExistingValue, JsonSerializer serializer)
=> reader.Value is string { Length: 32 } value
? new(value)
: throw new JsonException($"Could not parse MD5 hash with token '{reader.Value}'");
public override void WriteJson(JsonWriter writer, Md5Hash? value, JsonSerializer serializer) {
if (value is null) writer.WriteNull();
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,7 +1,7 @@
using Discord.WebSocket;
using Newtonsoft.Json;
namespace MinecraftDiscordBot;
namespace MinecraftDiscordBot.Models;
public abstract class Message {
[JsonProperty("type")]
@ -40,19 +40,16 @@ public class ReplyMessage : Message {
public int Chunk { get; set; } = 1;
[JsonProperty("total", Required = Required.DisallowNull)]
public int Total { get; set; } = 1;
/// <summary>
/// If at least one packet was received where
/// </summary>
[JsonProperty("success", Required = Required.DisallowNull)]
public bool Success { get; set; } = true;
public ResultState State { get; set; } = ResultState.Successful;
public override string Type => "reply";
}
public class RequestMessage : Message {
public RequestMessage(int answerId, string method, Dictionary<string, string>? parameters = null) {
public RequestMessage(int answerId, string method, Dictionary<string, object>? parameters = null) {
AnswerId = answerId;
Method = method;
Parameters = (parameters ?? Enumerable.Empty<KeyValuePair<string, string>>())
Parameters = (parameters ?? Enumerable.Empty<KeyValuePair<string, object>>())
.ToDictionary(i => i.Key, i => i.Value);
}
[JsonProperty("id")]
@ -60,6 +57,12 @@ public class RequestMessage : Message {
[JsonProperty("method")]
public string Method { get; set; }
[JsonProperty("params")]
public Dictionary<string, string> Parameters { get; }
public Dictionary<string, object> Parameters { get; }
public override string Type => "request";
}
public enum ResultState {
Successful,
Unsuccessful,
Fatal
}

View File

@ -0,0 +1,30 @@
using Newtonsoft.Json;
using System.Diagnostics;
namespace MinecraftDiscordBot.Models;
[JsonConverter(typeof(ModItemIdJsonConverter))]
[DebuggerDisplay($"{{{nameof(ToString)}(),nq}}")]
public class ModItemId {
public ModItemId(string name) {
var colon = name.IndexOf(':');
if (colon < 0) throw new ArgumentException("Invalid mod item id!", nameof(name));
ModName = name[..colon];
ModItem = name[(colon + 1)..];
if (ToString() != name) throw new InvalidProgramException("Bad Parsing!");
}
public override string ToString() => $"{ModName}:{ModItem}";
public string ModName { get; }
public string ModItem { get; }
public class ModItemIdJsonConverter : JsonConverter<ModItemId> {
public override ModItemId? ReadJson(JsonReader reader, Type objectType, ModItemId? existingValue, bool hasExistingValue, JsonSerializer serializer)
=> reader.Value is string value
? new(value)
: throw new JsonException($"Could not parse mod name with token '{reader.Value}'");
public override void WriteJson(JsonWriter writer, ModItemId? value, JsonSerializer serializer) {
if (value is null) writer.WriteNull();
else writer.WriteValue(value.ToString());
}
}
}

View File

@ -4,15 +4,15 @@ using Discord.Commands;
using Discord.Rest;
using Discord.WebSocket;
using Fleck;
using Newtonsoft.Json;
using OneOf;
using MinecraftDiscordBot.Commands;
using MinecraftDiscordBot.Services;
using System.Collections.Concurrent;
using System.Reflection;
using System.Runtime.CompilerServices;
namespace MinecraftDiscordBot;
public class Program : IDisposable, ICommandHandler<ResponseType> {
public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleManager {
public const string WebSocketSource = "WebSocket";
public const string BotSource = "Bot";
private static readonly object LogLock = new();
@ -24,21 +24,36 @@ public class Program : IDisposable, ICommandHandler<ResponseType> {
private readonly WebSocketServer _wssv;
private readonly BotConfiguration _config;
private readonly HashSet<ulong> _whitelistedChannels;
private readonly ConcurrentDictionary<Guid, ConnectedComputer> _connections = new();
private readonly ConcurrentDictionary<Guid, RootCommandService> _connections = new();
private static readonly char[] WhiteSpace = new char[] { '\t', '\n', ' ', '\r' };
public ITextChannel[] _channels = Array.Empty<ITextChannel>();
private ConnectedComputer? _rsSystem = null;
private RootCommandService? _rsSystem = null;
private bool disposedValue;
public static bool OnlineNotifications => false;
public static bool OnlineNotifications => true;
private const string ClientScriptName = "MinecraftDiscordBot.ClientScript.lua";
public readonly string ClientScript;
private readonly ITokenProvider _tokenProvider = new TimeoutTokenProvider(InstanceId, 10);
private static readonly int InstanceId = new Random().Next();
public ConnectedComputer? Computer {
private string GetVerifiedClientScript() => ClientScript
.Replace("$TOKEN", _tokenProvider.GenerateToken());
private static string GetClientScript(BotConfiguration config) {
using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(ClientScriptName);
if (stream is null) throw new FileNotFoundException("Client script could not be loaded!");
using var sr = new StreamReader(stream);
return sr.ReadToEnd()
.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 Refined Storage went offline. Please check the server!"
: $"The Refined Storage is back online!")));
? $"The Minecraft client has gone offline!"
: $"The Minecraft client is now online!")));
}
}
}
@ -46,6 +61,8 @@ public class Program : IDisposable, ICommandHandler<ResponseType> {
private async Task Broadcast(Func<ITextChannel, Task<IUserMessage>> message) => _ = await Task.WhenAll(_channels.Select(message));
public Program(BotConfiguration config) {
_config = config;
_administrators = config.Administrators.ToHashSet();
ClientScript = GetClientScript(config);
_client.Log += LogAsync;
_client.MessageReceived += (msg) => DiscordMessageReceived(msg);
_client.ReactionAdded += DiscordReactionAdded;
@ -75,11 +92,11 @@ public class Program : IDisposable, ICommandHandler<ResponseType> {
private static Task<int> RunWithConfig(IBotConfigurator arg) => new Program(arg.Config).RunAsync();
public async Task<int> RunAsync() {
StartWebSocketServer();
await _client.LoginAsync(TokenType.Bot, _config.Token);
await _client.StartAsync();
if (!await HasValidChannels())
return 1;
StartWebSocketServer();
// Block this task until the program is closed.
await Task.Delay(-1);
@ -120,10 +137,36 @@ public class Program : IDisposable, ICommandHandler<ResponseType> {
}
}
private static async Task SocketReceived(IWebSocketConnection socket, string message)
=> await LogInfoAsync(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Received: {message}");
private async Task SocketReceived(IWebSocketConnection socket, string message) {
await LogInfoAsync(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Received: {message}");
await (message switch {
"getcode" => SendClientCode(socket),
string s when s.StartsWith("login=") => ClientComputerConnected(socket, s[6..]),
_ => DisruptClientConnection(socket, "Protocol violation!")
});
}
private void AddComputerSocket(IWebSocketConnection socket, ConnectedComputer pc) => Computer = pc;
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));
}
private static async Task DisruptClientConnection(IWebSocketConnection socket, string reason) {
await socket.Send(reason);
await LogWarningAsync(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Client will be terminated, reason: {reason}");
socket.Close();
}
private async Task SendClientCode(IWebSocketConnection socket) {
await socket.Send(GetVerifiedClientScript());
await LogInfoAsync(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Script sent to client!");
}
private void AddComputerSocket(IWebSocketConnection socket, RootCommandService pc) => Computer = pc;
private void RemoveComputerSocket(IWebSocketConnection socket) {
if (Computer is { ConnectionInfo.Id: Guid id } && id == socket.ConnectionInfo.Id) Computer = null;
@ -134,19 +177,18 @@ public class Program : IDisposable, ICommandHandler<ResponseType> {
await LogInfoAsync(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Client disconnected!");
}
private async Task SocketOpened(IWebSocketConnection socket) {
AddComputerSocket(socket, new(socket));
await LogInfoAsync(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Client connected from {socket.ConnectionInfo.ClientIpAddress}:{socket.ConnectionInfo.ClientPort}!");
}
private static async Task SocketOpened(IWebSocketConnection socket) => await LogInfoAsync(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Client connected from {socket.ConnectionInfo.ClientIpAddress}:{socket.ConnectionInfo.ClientPort}!");
private async Task DiscordMessageReceived(SocketMessage arg, int timeout = 10000) {
if (arg is not SocketUserMessage message) return;
if (message.Author.IsBot) return;
if (!IsChannelWhitelisted(arg.Channel)) return;
if (arg.Type is not MessageType.Default) return;
var cts = new CancellationTokenSource(timeout);
if (IsCommand(message, out var argPos)) {
await arg.Channel.TriggerTypingAsync();
var parameters = message.Content[argPos..].Split(WhiteSpace, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
_ = Task.Run(async () => {
var response = await HandleCommand(message, parameters, cts.Token);
@ -166,6 +208,7 @@ public class Program : IDisposable, ICommandHandler<ResponseType> {
};
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) {
var msgObject = await message.GetOrDownloadAsync();
@ -195,8 +238,8 @@ public class Program : IDisposable, ICommandHandler<ResponseType> {
} catch (TaskCanceledException) {
return ResponseType.AsString("Your request could not be processed in time!");
} catch (ReplyException e) {
await LogWarningAsync(BotSource, e.Message);
return ResponseType.AsString($"Your request failed: {e.Message}");
await LogInfoAsync(BotSource, e.Message);
return ResponseType.AsString(e.Message);
} catch (Exception e) {
await LogErrorAsync(BotSource, e);
return ResponseType.AsString($"Oopsie doopsie, this should not have happened!");
@ -254,6 +297,11 @@ public class Program : IDisposable, ICommandHandler<ResponseType> {
Dispose(disposing: true);
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 abstract class ResponseType {

View File

@ -0,0 +1,166 @@
using Discord.WebSocket;
using MinecraftDiscordBot.Commands;
using MinecraftDiscordBot.Models;
using System.Text;
using System.Text.RegularExpressions;
namespace MinecraftDiscordBot.Services;
public class RefinedStorageService : CommandRouter {
private readonly ITaskWaitSource _taskSource;
private readonly IUserRoleManager _roleManager;
public override string HelpTextPrefix => "!rs ";
public RefinedStorageService(ITaskWaitSource taskSource, IUserRoleManager roleManager) : base() {
_taskSource = taskSource;
_roleManager = roleManager;
}
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 const string CmdEnergyUsage = "energyusage";
private const string CmdEnergyStorage = "energystorage";
private const string CmdListItems = "listitems";
private const string CmdItemName = "itemname";
private const string CmdListFluids = "listfluids";
private const string CmdCraftItem = "craft";
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> 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<Fluid>> ListFluidsAsync(CancellationToken ct) => await Method(CmdListFluids, RootCommandService.Deserialize<IEnumerable<Fluid>>(), ct);
public async Task<Item> GetItemData(string itemid, CancellationToken ct) => await Method(CmdGetItem, RootCommandService.Deserialize<Item>(), ct, new() {
["name"] = itemid
});
public async Task<Item> GetItemData(Md5Hash fingerprint, CancellationToken ct) => await Method(CmdGetItem, RootCommandService.Deserialize<Item>(), ct, new() {
["fingerprint"] = fingerprint.ToString()
});
public async Task<bool> CraftItem(string itemid, int amount, CancellationToken ct) => await Method(CmdCraftItem, RootCommandService.Deserialize<bool>(), ct, new() {
["name"] = itemid,
["count"] = amount
});
public async Task<LuaPackedArray> RawCommand(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)
=> FilterItems(message, filters.Select(ItemFilter.Parse), ct);
private async Task<IEnumerable<Item>> FilterItems(SocketUserMessage message, IEnumerable<ItemFilter> filters, CancellationToken ct) {
var items = Items?.ToList().AsEnumerable();
if (items is null) items = (await RefreshItemList(ct)).ToList();
foreach (var filter in filters)
items = items.Where(filter.MatchItem);
return items.ToList();
}
private async Task<List<Item>> RefreshItemList(CancellationToken ct) {
var response = await ListItemsAsync(ct);
lock (_itemLock) {
Items = response.OrderByDescending(i => i.Amount).ToList();
return Items;
}
}
private List<Item>? Items;
private readonly object _itemLock = new();
[CommandHandler(CmdEnergyStorage, HelpText = "Get the amount of energy stored in the RS system.")]
public async Task<ResponseType> HandleEnergyStorage(SocketUserMessage message, string[] parameters, CancellationToken ct)
=> ResponseType.AsString($"Refined Storage system stores {await GetEnergyStorageAsync(ct)} RF/t");
[CommandHandler(CmdEnergyUsage, HelpText = "Get the amount of energy used by the RS system.")]
public async Task<ResponseType> HandleEnergyUsage(SocketUserMessage message, string[] parameters, CancellationToken ct)
=> ResponseType.AsString($"Refined Storage system currently uses {await GetEnergyUsageAsync(ct)} RF/t");
[CommandHandler(CmdCraftItem, HelpText = "Craft a specific item given an item ID and optionally an amount.")]
public async Task<ResponseType> HandleCraftItem(SocketUserMessage message, string[] parameters, CancellationToken ct) {
var amount = 1;
string itemid;
if (parameters.Length is 1 or 2) {
itemid = parameters[0];
if (parameters.Length is 2)
amount = int.TryParse(parameters[1], out var value)
? value
: throw new ReplyException($"I expected an amount to craft, not '{parameters[1]}'!");
} else return parameters.Length is < 1
? throw new ReplyException("You have to give me at least an item name!")
: 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 InvalidOperationException($"Forgot to match parameter length {parameters.Length}!");
return await CraftItem(itemid, amount, ct)
? ResponseType.AsString($"Alright, I'm starting to craft {amount} {itemid}.")
: ResponseType.AsString($"Nope, that somehow doesn't work!");
}
[CommandHandler(CmdGetItem, HelpText = "Get information about a specific item.")]
public async Task<ResponseType> HandleGetItemData(SocketUserMessage message, string[] parameters, CancellationToken ct) {
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!");
itemid = parameters[0];
var item = await (Md5Hash.TryParse(itemid, out var fingerprint)
? GetItemData(fingerprint, ct)
: GetItemData(itemid, ct));
var sb = new StringBuilder();
sb.Append($"We currently have {item.Amount:n0} {item.CleanDisplayName}!");
if (item.Tags is not null and var tags) {
sb.AppendLine("\nThis item has the following tags:");
sb.AppendJoin('\n', tags.Select(tag => $"- {tag}"));
}
sb.Append($"\nRefer to this item with fingerprint {item.Fingerprint}");
return ResponseType.AsString(sb.ToString());
}
[CommandHandler(CmdItemName, HelpText = "Filter items by name.")]
public async Task<ResponseType> HandleItemName(SocketUserMessage message, string[] parameters, CancellationToken ct) {
if (parameters.Length < 2) return ResponseType.AsString($"Usage: {CmdItemName} filters...");
else {
var items = await FilterItems(message, parameters[1..], ct);
var sb = new StringBuilder();
sb.AppendLine("Did you mean:");
sb.AppendJoin("\n", items.Select(i => i.ToString()));
return ResponseType.AsString(sb.ToString());
}
}
[CommandHandler(CmdListFluids, HelpText = "Gets a list of fluids that are currently stored in the RS system.")]
public async Task<ResponseType> HandleFluidListing(SocketUserMessage message, string[] parameters, CancellationToken ct) {
var sb = new StringBuilder();
sb.Append("The Refined Storage system stores those fluids:");
var fluids = await ListFluidsAsync(ct);
foreach (var fluid in fluids.OrderByDescending(i => i.Amount))
if (fluid.Amount > 10000) sb.AppendFormat("\n{0:n2} B of {1}", fluid.Amount / 1000.0f, fluid.DisplayName);
else sb.AppendFormat("\n{0:n0} mB of {1}", fluid.Amount, fluid.DisplayName);
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 RawCommand(command, ct);
return ResponseType.AsString(response.ToString());
}
[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) {
var sb = new StringBuilder();
sb.Append("The Refined Storage system currently stores these items:");
var items = await RefreshItemList(ct);
lock (_itemLock) {
var taken = 0;
foreach (var item in items) {
if (sb.Length > 500) break;
sb.AppendFormat("\n{0:n0}x {1}", item.Amount, item.DisplayName);
taken++;
}
if (items.Count > taken) sb.AppendFormat("\nand {0} more items.", items.Skip(taken).Sum(i => i.Amount));
}
return ResponseType.AsString(sb.ToString());
}
}

View File

@ -0,0 +1,11 @@
using System.Runtime.Serialization;
namespace MinecraftDiscordBot.Services;
[Serializable]
public class ReplyException : Exception {
public ReplyException() { }
public ReplyException(string message) : base(message) { }
public ReplyException(string message, Exception inner) : base(message, inner) { }
protected ReplyException(SerializationInfo info, StreamingContext context) : base(info, context) { }
}

View File

@ -0,0 +1,75 @@
using Discord.WebSocket;
using Fleck;
using MinecraftDiscordBot.Commands;
using MinecraftDiscordBot.Models;
using Newtonsoft.Json;
namespace MinecraftDiscordBot.Services;
public delegate Task<TResponse> HandleCommandDelegate<TResponse>(SocketUserMessage message, string[] parameters, CancellationToken ct);
public delegate Task HandleCommandDelegate(SocketUserMessage message, string[] parameters, CancellationToken ct);
public class RootCommandService : CommandRouter, ITaskWaitSource {
protected readonly IWebSocketConnection _socket;
public override string HelpTextPrefix => "!";
public RootCommandService(IWebSocketConnection socket, IUserRoleManager roleManager) : base() {
socket.OnMessage = OnMessage;
_socket = socket;
_rs = new RefinedStorageService(this, roleManager);
}
private void OnMessage(string message) {
if (JsonConvert.DeserializeObject<ReplyMessage>(message) is not ReplyMessage msg) return;
IChunkWaiter? waiter;
lock (_syncRoot) if (!_waits.TryGetValue(msg.AnswerId, out waiter)) {
Program.LogWarningAsync("Socket", $"Invalid wait id '{msg.AnswerId}'!");
return;
}
waiter.SetResultState(msg.State);
waiter.AddChunk(msg.Chunk, msg.Total, msg.Result);
if (waiter.Finished || waiter.IsCancellationRequested)
lock (_syncRoot)
_waits.Remove(waiter.ID);
}
public Task Send(string message) => _socket.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;
while (true) {
var id = _rnd.Next();
if (!_waits.ContainsKey(id))
return id;
Program.LogWarningAsync(Program.WebSocketSource, $"Could not get a free ID after {++attempts} attempts!");
}
}
public ChunkWaiter<T> GetWaiter<T>(Func<string, T> resultParser, CancellationToken ct) {
ChunkWaiter<T> waiter;
lock (_syncRoot) {
waiter = new ChunkWaiter<T>(GetFreeId(), resultParser, ct);
_waits.Add(waiter.ID, waiter);
}
return waiter;
}
private readonly ICommandHandler<ResponseType> _rs;
[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);
public static Func<string, T> Deserialize<T>() => msg
=> JsonConvert.DeserializeObject<T>(msg) ?? throw new InvalidProgramException("Empty response!");
public override Task<ResponseType> FallbackHandler(SocketUserMessage message, string method, string[] parameters, CancellationToken ct)
=> throw new ReplyException($"What the fuck do you mean by '{method}'?");
}
public interface ITaskWaitSource {
ChunkWaiter<T> GetWaiter<T>(Func<string, T> resultParser, CancellationToken ct);
Task Send(Message requestMessage);
}

View File

@ -0,0 +1,36 @@
namespace MinecraftDiscordBot;
public class TimeoutTokenProvider : ITokenProvider {
public TimeoutTokenProvider(int instanceId, int timeoutSeconds, ICipher? cipher = null) {
InstancePrefix = Convert.ToHexString(BitConverter.GetBytes(instanceId));
_timeout = timeoutSeconds;
_cipher = cipher ?? new AesCipher();
}
private readonly ICipher _cipher;
private readonly int _timeout;
public string InstancePrefix { get; }
public bool VerifyToken(string token) {
if (!token.StartsWith(InstancePrefix)) return false;
token = token[InstancePrefix.Length..];
byte[] data;
try {
data = _cipher.Decrypt(Convert.FromHexString(token));
} catch (Exception e) {
Program.LogError("TokenProvider", e);
return false;
}
var when = DateTime.FromBinary(BitConverter.ToInt64(data, 0));
return when >= DateTime.UtcNow.AddSeconds(-_timeout);
}
public string GenerateToken() {
var time = BitConverter.GetBytes(DateTime.UtcNow.ToBinary());
var key = Guid.NewGuid().ToByteArray();
var token = InstancePrefix + Convert.ToHexString(_cipher.Encrypt(time.Concat(key).ToArray()));
return token;
}
}
public interface ITokenProvider {
string GenerateToken();
bool VerifyToken(string token);
}