10 Commits
1.0.1 ... 1.1.0

Author SHA1 Message Date
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
9406aaa050 Finished routing with automatic help text generation
Changelog: added
2022-01-16 15:58:35 +01:00
ede4efa4e3 Added command routing
Added router class using attributes
Added success parameter to mc computer response
Added generic answer type class (for future choice results)

Changelog: added
2022-01-15 21:26:32 +01:00
bef9d16888 Synchronous logging
Changelog: fixed
2022-01-12 19:03:51 +01:00
2be3a6e0c7 Added Assembly info and versioning
Changelog: added
2022-01-12 15:14:59 +01:00
49bc63aad9 Add CHANGELOG 2022-01-12 13:54:02 +00:00
22 changed files with 890 additions and 398 deletions

1
CHANGELOG Normal file
View File

@ -0,0 +1 @@

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,9 @@ public class BotConfiguration : IBotConfiguration, IBotConfigurator {
[JsonProperty("prefix", Required = Required.DisallowNull)] [JsonProperty("prefix", Required = Required.DisallowNull)]
[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)]
[Option("host", Default = DEFAULT_PREFIX, HelpText = "The Discord bot command prefix", Required = true)]
public string SocketHost { get; init; } = default!;
[JsonIgnore] [JsonIgnore]
public BotConfiguration Config => this; public BotConfiguration Config => this;
} }

View File

@ -0,0 +1,45 @@
using MinecraftDiscordBot.Services;
namespace MinecraftDiscordBot;
public class ChunkWaiter<T> : IChunkWaiter {
public int ID { get; }
private readonly CancellationToken _ct;
public ChunkWaiter(int id, Func<string, T> resultParser, CancellationToken ct) {
ID = id;
this.resultParser = resultParser;
_ct = ct;
}
private readonly TaskCompletionSource<T> tcs = new();
private readonly Func<string, T> resultParser;
public Task<T> Task => tcs.Task.WaitAsync(_ct);
public bool Finished { get; private set; } = false;
public bool IsCancellationRequested => _ct.IsCancellationRequested;
private string?[]? _chunks = null;
private int _receivedChunks = 0;
private bool _success = true;
private readonly object _syncRoot = new();
public void AddChunk(int chunkId, int totalChunks, string value) {
lock (_syncRoot) {
if (_chunks is null) _chunks = new string[totalChunks];
else if (_chunks.Length != totalChunks) {
Program.LogErrorAsync(Program.WebSocketSource, new InvalidOperationException("Different numbers of chunks in same message ID!"));
return;
}
ref string? chunk = ref _chunks[chunkId - 1]; // Lua 1-indexed
if (chunk is not null) {
Program.LogErrorAsync(Program.WebSocketSource, new InvalidOperationException($"Chunk with ID {chunkId} was already received!"));
return;
}
chunk = value;
}
if (++_receivedChunks == totalChunks) FinalizeResult(_chunks);
}
private void FinalizeResult(string?[] _chunks) {
var resultString = string.Concat(_chunks);
if (_success) tcs.SetResult(resultParser(resultString));
else tcs.SetException(new ReplyException(resultString));
Finished = true;
}
public void SetUnsuccessful() => _success = false;
}

View File

@ -0,0 +1,139 @@
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 = {}
local i = 1
for i=1,total do
local pos = 1 + ((i - 1) * chunkSize)
chunks[i] = value:sub(pos, pos + chunkSize - 1)
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 = true end
if not success then
sendJson(socket, { id = id, result = result, success = success })
return
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("No peripheral '"..name.."' attached to the computer!") end
return dev
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
return textutils.serializeJSON(getPeripheral("rsBridge").getItem(parsed.params))
end
error("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)
sendResponse(socket, parsed.id, result, success)
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

@ -0,0 +1,46 @@
using Discord.WebSocket;
using MinecraftDiscordBot.Services;
using System.Reflection;
using System.Text;
namespace MinecraftDiscordBot.Commands;
public abstract class CommandRouter : ICommandHandler<ResponseType> {
public record struct HandlerStruct(HandleCommandDelegate<ResponseType> Delegate, CommandHandlerAttribute Attribute);
private readonly Dictionary<string, HandlerStruct> _handlers = new();
public abstract string HelpTextPrefix { get; }
public CommandRouter() {
foreach (var method in GetType().GetMethods())
if (GetHandlerAttribute(method) is CommandHandlerAttribute attribute)
try {
_handlers.Add(attribute.CommandName, new(method.CreateDelegate<HandleCommandDelegate<ResponseType>>(this), attribute));
} catch (Exception) {
Program.LogWarning("CommandRouter", $"Could not add delegate for method {attribute.CommandName} in function {method.ReturnType} {method.Name}(...)!");
throw;
}
}
[CommandHandler("help", HelpText = "Show this help information!")]
public virtual Task<ResponseType> GetHelpText(SocketUserMessage message, string[] parameters, CancellationToken ct)
=> 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 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 }
? RootAnswer(message, ct)
: _handlers.TryGetValue(parameters[0], out var handler)
? handler.Delegate(message, parameters[1..], ct)
: FallbackHandler(message, parameters[0], parameters[1..], ct);
private string GenerateHelp() {
var sb = new StringBuilder();
sb.Append("Command usage:");
foreach (var (name, handler) in _handlers) {
sb.Append($"\n{HelpTextPrefix}{name}");
if (handler.Attribute.HelpText is string help)
sb.Append($": {help}");
}
return sb.ToString();
}
}

View File

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

View File

@ -1,340 +0,0 @@
using Discord;
using Discord.WebSocket;
using Fleck;
using Newtonsoft.Json;
using System.Diagnostics;
using System.Text;
namespace MinecraftDiscordBot;
public class ConnectedComputer {
protected readonly IWebSocketConnection _socket;
public ConnectedComputer(IWebSocketConnection socket) {
socket.OnMessage = OnMessage;
_socket = socket;
}
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.LogWarning("Socket", $"Invalid wait id '{msg.AnswerId}'!");
return;
}
}
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);
protected 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;
protected interface IChunkWaiter {
bool Finished { get; }
int ID { get; }
bool IsCancellationRequested { get; }
void AddChunk(int chunkId, int totalChunks, string value);
}
protected class ChunkWaiter<T> : IChunkWaiter {
public int ID { get; }
private readonly CancellationToken _ct;
public ChunkWaiter(int id, Func<string, T> resultParser, CancellationToken ct) {
ID = id;
this.resultParser = resultParser;
_ct = ct;
}
private readonly TaskCompletionSource<T> tcs = new();
private readonly Func<string, T> resultParser;
public Task<T> Task => tcs.Task.WaitAsync(_ct);
public bool Finished { get; private set; } = false;
public bool IsCancellationRequested => _ct.IsCancellationRequested;
private string?[]? _chunks = null;
private int _receivedChunks = 0;
private readonly object _syncRoot = new();
public void AddChunk(int chunkId, int totalChunks, string value) {
lock (_syncRoot) {
if (_chunks is null) _chunks = new string[totalChunks];
else if (_chunks.Length != totalChunks) {
Program.LogError(Program.WebSocketSource, new InvalidOperationException("Different numbers of chunks in same message ID!"));
return;
}
ref string? chunk = ref _chunks[chunkId - 1]; // Lua 1-indexed
if (chunk is not null) {
Program.LogError(Program.WebSocketSource, new InvalidOperationException($"Chunk with ID {chunkId} was already received!"));
return;
}
chunk = value;
}
if (++_receivedChunks == totalChunks) FinalizeResult(_chunks);
}
private void FinalizeResult(string?[] _chunks) {
tcs.SetResult(resultParser(string.Concat(_chunks)));
Finished = true;
}
}
protected int GetFreeId() {
var attempts = 0;
while (true) {
var id = _rnd.Next();
if (!_waits.ContainsKey(id))
return id;
Program.LogWarning(Program.WebSocketSource, $"Could not get a free ID after {++attempts} attempts!");
}
}
protected 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;
}
protected static Func<string, T> Deserialize<T>() => msg
=> JsonConvert.DeserializeObject<T>(msg) ?? throw new InvalidProgramException("Empty response!");
}
public class RefinedStorageComputer : ConnectedComputer {
public const string Role = "rs";
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 RefinedStorageComputer(IWebSocketConnection socket) : base(socket) { }
public async Task<int> GetEnergyUsageAsync(CancellationToken ct) {
var waiter = GetWaiter(int.Parse, ct);
await Send(new RequestMessage(waiter.ID, CmdEnergyUsage));
return await waiter.Task;
}
public async Task<int> GetEnergyStorageAsync(CancellationToken ct) {
var waiter = GetWaiter(int.Parse, ct);
await Send(new RequestMessage(waiter.ID, CmdEnergyStorage));
return await waiter.Task;
}
public async Task<IEnumerable<Item>> ListItemsAsync(CancellationToken ct) {
var waiter = GetWaiter(Deserialize<IEnumerable<Item>>(), ct);
await Send(new RequestMessage(waiter.ID, CmdListItems));
return await waiter.Task;
}
public async Task<IEnumerable<Fluid>> ListFluidsAsync(CancellationToken ct) {
var waiter = GetWaiter(Deserialize<IEnumerable<Fluid>>(), ct);
await Send(new RequestMessage(waiter.ID, CmdListFluids));
return await waiter.Task;
}
public async Task HandleCommand(SocketUserMessage message, string[] parameters, CancellationToken ct) {
if (parameters is not { Length: > 0 }) {
await message.ReplyAsync($"Refined Storage system is online");
return;
}
try {
switch (parameters[0].ToLower()) {
case CmdEnergyUsage:
await message.ReplyAsync($"Refined Storage system currently uses {await GetEnergyUsageAsync(ct)} RF/t");
break;
case CmdEnergyStorage:
await message.ReplyAsync($"Refined Storage system stores {await GetEnergyStorageAsync(ct)} RF/t");
break;
case CmdListItems:
await HandleItemListing(message, ct);
break;
case CmdItemName:
await HandleItemName(message, parameters, ct);
break;
case CmdListFluids:
await HandleFluidListing(message, ct);
break;
case string other:
await message.ReplyAsync($"Refined Storages cannot do '{other}', bruh");
break;
}
} catch (TaskCanceledException) {
await message.ReplyAsync("The Refined Storage system request timed out!");
}
}
private async Task HandleItemName(SocketUserMessage message, string[] parameters, CancellationToken ct) {
if (parameters.Length < 2) await message.ReplyAsync($"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()));
await message.ReplyAsync(sb.ToString());
}
}
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();
}
public abstract class ItemFilter {
public abstract bool Match(Fluid item);
public virtual bool MatchItem(Item item) => Match(item);
public static ItemFilter Parse(string filter)
=> filter.StartsWith('@')
? new ModNameFilter(filter[1..])
: filter.StartsWith('$')
? new TagFilter(filter[1..])
: new ItemNameFilter(filter);
private class ModNameFilter : ItemFilter {
private readonly string filter;
public ModNameFilter(string filter) => this.filter = filter;
public override bool Match(Fluid item) => item.ItemId.ModName.Contains(filter, StringComparison.InvariantCultureIgnoreCase);
}
private class TagFilter : ItemFilter {
private readonly string filter;
public TagFilter(string filter) => this.filter = filter;
public override bool Match(Fluid item)
=> item.Tags?.Any(tag => tag.Contains(filter, StringComparison.InvariantCultureIgnoreCase)) ?? false;
}
private class ItemNameFilter : ItemFilter {
private readonly string filter;
public ItemNameFilter(string filter) => this.filter = filter;
public override bool Match(Fluid item) => item.DisplayName.Contains(filter, StringComparison.InvariantCultureIgnoreCase);
}
}
private async Task HandleFluidListing(SocketUserMessage message, 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);
await message.ReplyAsync(sb.ToString());
}
private List<Item>? Items;
private readonly object _itemLock = new();
private async Task HandleItemListing(SocketUserMessage message, 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));
}
await message.ReplyAsync(sb.ToString());
}
private async Task<List<Item>> RefreshItemList(CancellationToken ct) {
var response = await ListItemsAsync(ct);
lock (_itemLock) {
Items = response.OrderByDescending(i => i.Amount).ToList();
return Items;
}
}
}
[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

@ -0,0 +1,9 @@
namespace MinecraftDiscordBot;
public interface IChunkWaiter {
bool Finished { get; }
int ID { get; }
bool IsCancellationRequested { get; }
void AddChunk(int chunkId, int totalChunks, string value);
void SetUnsuccessful();
}

View File

@ -0,0 +1,34 @@
using MinecraftDiscordBot.Models;
namespace MinecraftDiscordBot;
public abstract class ItemFilter {
public abstract bool Match(Fluid item);
public virtual bool MatchItem(Item item) => Match(item);
public static ItemFilter Parse(string filter)
=> filter.StartsWith('@')
? new ModNameFilter(filter[1..])
: filter.StartsWith('$')
? new TagFilter(filter[1..])
: new ItemNameFilter(filter);
private class ModNameFilter : ItemFilter {
private readonly string filter;
public ModNameFilter(string filter) => this.filter = filter;
public override bool Match(Fluid item) => item.ItemId.ModName.Contains(filter, StringComparison.InvariantCultureIgnoreCase);
}
private class TagFilter : ItemFilter {
private readonly string filter;
public TagFilter(string filter) => this.filter = filter;
public override bool Match(Fluid item)
=> item.Tags?.Any(tag => tag.Contains(filter, StringComparison.InvariantCultureIgnoreCase)) ?? false;
}
private class ItemNameFilter : ItemFilter {
private readonly string filter;
public ItemNameFilter(string filter) => this.filter = filter;
public override bool Match(Fluid item) => item.DisplayName.Contains(filter, StringComparison.InvariantCultureIgnoreCase);
}
}

View File

@ -6,8 +6,19 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<Version>1.1.0</Version>
<Authors>Michael Chen</Authors>
<Company>$(Authors)</Company>
<RepositoryUrl>https://gitlab.com/chenmichael/mcdiscordbot</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<AssemblyVersion>$(VersionPrefix)</AssemblyVersion>
<FileVersion>$(VersionPrefix)</FileVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="ClientScript.lua" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.8.0" /> <PackageReference Include="CommandLineParser" Version="2.8.0" />
<PackageReference Include="Discord.Net" Version="3.1.0" /> <PackageReference Include="Discord.Net" Version="3.1.0" />
@ -17,7 +28,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Folder Include="Properties\" /> <Resource Include="ClientScript.lua" />
</ItemGroup> </ItemGroup>
</Project> </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,34 @@
using Newtonsoft.Json;
using System.Diagnostics;
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);
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,7 +1,7 @@
using Discord.WebSocket; using Discord.WebSocket;
using Newtonsoft.Json; using Newtonsoft.Json;
namespace MinecraftDiscordBot; namespace MinecraftDiscordBot.Models;
public abstract class Message { public abstract class Message {
[JsonProperty("type")] [JsonProperty("type")]
@ -11,7 +11,7 @@ public abstract class Message {
public class CapabilityMessage : Message { public class CapabilityMessage : Message {
public override string Type => "roles"; public override string Type => "roles";
[JsonProperty("role", Required = Required.Always)] [JsonProperty("role", Required = Required.Always)]
public string Role { get; set; } = default!; public string[] Role { get; set; } = default!;
} }
public class TextMessage : Message { public class TextMessage : Message {
@ -36,18 +36,23 @@ public class ReplyMessage : Message {
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; } public string Result { get; set; }
[JsonProperty("chunk", Required = Required.Always)] [JsonProperty("chunk", Required = Required.DisallowNull)]
public int Chunk { get; set; } public int Chunk { get; set; } = 1;
[JsonProperty("total", Required = Required.Always)] [JsonProperty("total", Required = Required.DisallowNull)]
public int Total { get; set; } 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 override string Type => "reply"; public override string Type => "reply";
} }
public class RequestMessage : Message { 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; AnswerId = answerId;
Method = method; Method = method;
Parameters = (parameters ?? Enumerable.Empty<KeyValuePair<string, string>>()) Parameters = (parameters ?? Enumerable.Empty<KeyValuePair<string, object>>())
.ToDictionary(i => i.Key, i => i.Value); .ToDictionary(i => i.Key, i => i.Value);
} }
[JsonProperty("id")] [JsonProperty("id")]
@ -55,6 +60,6 @@ public class RequestMessage : Message {
[JsonProperty("method")] [JsonProperty("method")]
public string Method { get; set; } public string Method { get; set; }
[JsonProperty("params")] [JsonProperty("params")]
public Dictionary<string, string> Parameters { get; } public Dictionary<string, object> Parameters { get; }
public override string Type => "request"; public override string Type => "request";
} }

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

@ -1,20 +1,22 @@
using CommandLine; using CommandLine;
using Discord; using Discord;
using Discord.Commands; using Discord.Commands;
using Discord.Rest; using Discord.Rest;
using Discord.WebSocket; using Discord.WebSocket;
using Fleck; using Fleck;
using Newtonsoft.Json; using MinecraftDiscordBot.Commands;
using MinecraftDiscordBot.Services;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
namespace MinecraftDiscordBot; namespace MinecraftDiscordBot;
public class Program : IDisposable { 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();
public const int ChoiceTimeout = 20 * 1000;
private readonly DiscordSocketClient _client = new(new() { private readonly DiscordSocketClient _client = new(new() {
LogLevel = LogSeverity.Verbose, LogLevel = LogSeverity.Verbose,
GatewayIntents = GatewayIntents.AllUnprivileged & ~(GatewayIntents.GuildScheduledEvents | GatewayIntents.GuildInvites) GatewayIntents = GatewayIntents.AllUnprivileged & ~(GatewayIntents.GuildScheduledEvents | GatewayIntents.GuildInvites)
@ -22,19 +24,36 @@ public class Program : IDisposable {
private readonly WebSocketServer _wssv; private readonly WebSocketServer _wssv;
private readonly BotConfiguration _config; private readonly BotConfiguration _config;
private readonly HashSet<ulong> _whitelistedChannels; 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' }; private static readonly char[] WhiteSpace = new char[] { '\t', '\n', ' ', '\r' };
public ITextChannel[] _channels = Array.Empty<ITextChannel>(); public ITextChannel[] _channels = Array.Empty<ITextChannel>();
private RefinedStorageComputer? _rsSystem = null; private RootCommandService? _rsSystem = null;
private bool disposedValue; private bool disposedValue;
public static bool OnlineNotifications => false;
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 RefinedStorageComputer? RsSystem { private string GetVerifiedClientScript() => ClientScript
.Replace("$TOKEN", _tokenProvider.GenerateToken());
private 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 { get => _rsSystem; set {
if (_rsSystem != value) { if (_rsSystem != value) {
_rsSystem = value; _rsSystem = value;
_ = Task.Run(() => Broadcast(i => i.SendMessageAsync(value is null if (OnlineNotifications)
? $"The Refined Storage went offline. Please check the server!" _ = Task.Run(() => Broadcast(i => i.SendMessageAsync(value is null
: $"The Refined Storage is back online!"))); ? $"The Refined Storage went offline. Please check the server!"
: $"The Refined Storage is back online!")));
} }
} }
} }
@ -42,8 +61,10 @@ public class Program : IDisposable {
private async Task Broadcast(Func<ITextChannel, Task<IUserMessage>> message) => _ = await Task.WhenAll(_channels.Select(message)); 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;
ClientScript = GetClientScript(config);
_client.Log += LogAsync; _client.Log += LogAsync;
_client.MessageReceived += (msg) => DiscordMessageReceived(msg); _client.MessageReceived += (msg) => DiscordMessageReceived(msg);
_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
}; };
@ -70,11 +91,11 @@ public class Program : IDisposable {
private static Task<int> RunWithConfig(IBotConfigurator arg) => new Program(arg.Config).RunAsync(); private static Task<int> RunWithConfig(IBotConfigurator arg) => new Program(arg.Config).RunAsync();
public async Task<int> RunAsync() { public async Task<int> RunAsync() {
StartWebSocketServer();
await _client.LoginAsync(TokenType.Bot, _config.Token); await _client.LoginAsync(TokenType.Bot, _config.Token);
await _client.StartAsync(); await _client.StartAsync();
if (!await HasValidChannels()) if (!await HasValidChannels())
return 1; return 1;
StartWebSocketServer();
// Block this task until the program is closed. // Block this task until the program is closed.
await Task.Delay(-1); await Task.Delay(-1);
@ -83,7 +104,7 @@ public class Program : IDisposable {
private async Task<bool> HasValidChannels() { private async Task<bool> HasValidChannels() {
if (await GetValidChannels(_whitelistedChannels).ToArrayAsync() is not { Length: > 0 } channels) { if (await GetValidChannels(_whitelistedChannels).ToArrayAsync() is not { Length: > 0 } channels) {
await LogError(BotSource, new InvalidOperationException("No valid textchannel was whitelisted!")); await LogErrorAsync(BotSource, new InvalidOperationException("No valid textchannel was whitelisted!"));
return false; return false;
} }
_channels = channels; _channels = channels;
@ -100,51 +121,62 @@ public class Program : IDisposable {
foreach (var channelId in ids) { foreach (var channelId in ids) {
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 LogWarning(BotSource, $"Channel with id [{channelId}] does not exist!"); if (channel is null) await LogWarningAsync(BotSource, $"Channel with id [{channelId}] does not exist!");
else await LogWarning(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}]!");
continue; continue;
} }
if (textChannel.Guild is RestGuild guild) { if (textChannel.Guild is RestGuild guild) {
await guild.UpdateAsync(); await guild.UpdateAsync();
await LogInfo(BotSource, $"Whitelisted in channel: {channel.Name} [{channel.Id}] on server {guild.Name} [{guild.Id}]"); await LogInfoAsync(BotSource, $"Whitelisted in channel: {channel.Name} [{channel.Id}] on server {guild.Name} [{guild.Id}]");
} else { } else {
await LogWarning(BotSource, $"Whitelisted in channel: {channel.Name} [{channel.Id}] on unknown server!"); await LogWarningAsync(BotSource, $"Whitelisted in channel: {channel.Name} [{channel.Id}] on unknown server!");
} }
yield return textChannel; yield return textChannel;
} }
} }
private async Task SocketReceived(IWebSocketConnection socket, string message) { private async Task SocketReceived(IWebSocketConnection socket, string message) {
if (JsonConvert.DeserializeObject<CapabilityMessage>(message) is not CapabilityMessage capability) return; 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!")
});
}
try { private async Task ClientComputerConnected(IWebSocketConnection socket, string token) {
var pc = capability.Role switch { if (!_tokenProvider.VerifyToken(token)) {
RefinedStorageComputer.Role => new RefinedStorageComputer(socket), await DisruptClientConnection(socket, "outdated");
string role => throw new ArgumentException($"Invalid role '{role}'!") return;
};
AddComputerSocket(socket, pc);
await LogInfo(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Presented capability as {pc.GetType().Name}");
} catch (ArgumentException e) {
await LogError(WebSocketSource, e);
} }
await LogInfoAsync(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Client logged in with valid script!");
AddComputerSocket(socket, new(socket));
} }
private void AddComputerSocket(IWebSocketConnection socket, ConnectedComputer pc) { private static async Task DisruptClientConnection(IWebSocketConnection socket, string reason) {
if (pc is RefinedStorageComputer rs) RsSystem = rs; 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) { private void RemoveComputerSocket(IWebSocketConnection socket) {
if (RsSystem?.ConnectionInfo.Id == socket.ConnectionInfo.Id) RsSystem = 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) {
RemoveComputerSocket(socket); RemoveComputerSocket(socket);
await LogInfo(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Client disconnected!"); await LogInfoAsync(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Client disconnected!");
} }
private static async Task SocketOpened(IWebSocketConnection socket) private static async Task SocketOpened(IWebSocketConnection socket) => await LogInfoAsync(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Client connected from {socket.ConnectionInfo.ClientIpAddress}:{socket.ConnectionInfo.ClientPort}!");
=> await LogInfo(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Client connected from {socket.ConnectionInfo.ClientIpAddress}:{socket.ConnectionInfo.ClientPort}!");
private async Task DiscordMessageReceived(SocketMessage arg, int timeout = 10000) { private async Task DiscordMessageReceived(SocketMessage arg, int timeout = 10000) {
if (arg is not SocketUserMessage message) return; if (arg is not SocketUserMessage message) return;
@ -155,26 +187,61 @@ public class Program : IDisposable {
if (IsCommand(message, out var argPos)) { if (IsCommand(message, out var argPos)) {
var parameters = message.Content[argPos..].Split(WhiteSpace, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var parameters = message.Content[argPos..].Split(WhiteSpace, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
_ = Task.Run(() => HandleCommand(message, parameters, cts.Token)); _ = Task.Run(async () => {
var response = await HandleCommand(message, parameters, cts.Token);
await SendResponse(message, response);
});
return; return;
} }
await LogInfo("Discord", $"[{arg.Author.Username}] {arg.Content}"); await LogInfoAsync("Discord", $"[{arg.Author.Username}] {arg.Content}");
// TODO: Relay Message to Chat Receiver // TODO: Relay Message to Chat Receiver
} }
private Task HandleCommand(SocketUserMessage message, string[] parameters, CancellationToken ct) private Task SendResponse(SocketUserMessage message, ResponseType response) => response switch {
=> parameters is { Length: > 0 } ResponseType.IChoiceResponse res => HandleChoice(message, res),
? parameters[0].ToLower() switch { ResponseType.StringResponse res => message.ReplyAsync(res.Message),
RefinedStorageComputer.Role => HandleRefinedStorageCommand(message, parameters[1..], ct), _ => message.ReplyAsync($"Whoops, someone forgot to implement '{response.GetType()}' responses?"),
_ => message.ReplyAsync($"What the fuck do you mean by '{parameters[0]}'?") };
}
: message.ReplyAsync($"You really think an empty command works?");
private Task HandleRefinedStorageCommand(SocketUserMessage message, string[] parameters, CancellationToken ct) private readonly ConcurrentDictionary<ulong, ResponseType.IChoiceResponse> _choiceWait = new();
=> RsSystem is RefinedStorageComputer rs
? rs.HandleCommand(message, parameters, ct) private async Task DiscordReactionAdded(Cacheable<IUserMessage, ulong> message, Cacheable<IMessageChannel, ulong> channel, SocketReaction reaction) {
: message.ReplyAsync("The Refined Storage system is currently unavailable!"); var msgObject = await message.GetOrDownloadAsync();
if (reaction.UserId == _client.CurrentUser.Id) return;
if (!_choiceWait.TryRemove(message.Id, out var choice)) { await LogInfoAsync(BotSource, "Reaction was added to message without choice object!"); return; }
await msgObject.DeleteAsync();
await LogInfoAsync(BotSource, $"Reaction {reaction.Emote.Name} was added to the choice by {reaction.UserId}!");
}
private async Task HandleChoice(SocketUserMessage message, ResponseType.IChoiceResponse res) {
var reply = await message.ReplyAsync($"{res.Query}\n{string.Join("\n", res.Options)}");
_choiceWait[reply.Id] = res;
var reactions = new Emoji[] { new("0⃣")/*, new("1⃣"), new("2⃣"), new("3⃣"), new("4⃣"), new("5⃣"), new("6⃣"), new("7⃣"), new("8⃣"), new("9⃣")*/ };
await reply.AddReactionsAsync(reactions);
_ = Task.Run(async () => {
await Task.Delay(ChoiceTimeout);
_ = _choiceWait.TryRemove(message.Id, out _);
await reply.ModifyAsync(i => i.Content = "You did not choose in time!");
await reply.RemoveAllReactionsAsync();
});
}
public async Task<ResponseType> HandleCommand(SocketUserMessage message, string[] parameters, CancellationToken ct) {
if (Computer is ICommandHandler<ResponseType> handler)
try {
return await handler.HandleCommand(message, parameters, ct);
} catch (TaskCanceledException) {
return ResponseType.AsString("Your request could not be processed in time!");
} catch (ReplyException e) {
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!");
}
else return ResponseType.AsString("The Minecraft server is currently unavailable!");
}
private bool IsCommand(SocketUserMessage message, out int argPos) { private bool IsCommand(SocketUserMessage message, out int argPos) {
argPos = 0; argPos = 0;
@ -183,16 +250,19 @@ public class Program : IDisposable {
private bool IsChannelWhitelisted(ISocketMessageChannel channel) private bool IsChannelWhitelisted(ISocketMessageChannel channel)
=> _whitelistedChannels.Contains(channel.Id); => _whitelistedChannels.Contains(channel.Id);
public static ConfiguredTaskAwaitable LogInfo(string source, string message) => LogAsync(new(LogSeverity.Info, source, message)).ConfigureAwait(false); public static ConfiguredTaskAwaitable LogInfoAsync(string source, string message) => LogAsync(new(LogSeverity.Info, source, message)).ConfigureAwait(false);
public static ConfiguredTaskAwaitable LogWarning(string source, string message) => LogAsync(new(LogSeverity.Warning, source, message)).ConfigureAwait(false); public static ConfiguredTaskAwaitable LogWarningAsync(string source, string message) => LogAsync(new(LogSeverity.Warning, source, message)).ConfigureAwait(false);
public static ConfiguredTaskAwaitable LogError(string source, Exception exception) => LogAsync(new(LogSeverity.Error, source, exception?.Message, exception)).ConfigureAwait(false); public static ConfiguredTaskAwaitable LogErrorAsync(string source, Exception exception) => LogAsync(new(LogSeverity.Error, source, exception?.Message, exception)).ConfigureAwait(false);
public static void LogInfo(string source, string message) => Log(new(LogSeverity.Info, source, message));
public static void LogWarning(string source, string message) => Log(new(LogSeverity.Warning, source, message));
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) {
Log(msg); Log(msg);
await Task.CompletedTask; await Task.CompletedTask;
} }
private static void Log(LogMessage msg) { public static void Log(LogMessage msg) {
lock (LogLock) lock (LogLock)
Console.WriteLine(msg.ToString()); Console.WriteLine(msg.ToString());
} }
@ -224,3 +294,32 @@ public class Program : IDisposable {
GC.SuppressFinalize(this); GC.SuppressFinalize(this);
} }
} }
public abstract class ResponseType {
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 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 class StringResponse : ResponseType {
public StringResponse(string message) => Message = message;
public string Message { get; }
}
public interface IChoiceResponse {
IEnumerable<string> Options { get; }
string Query { get; }
Task HandleResult(int index);
}
public class ChoiceResponse<T> : ResponseType, IChoiceResponse {
private readonly Func<T, Task> _resultHandler;
private readonly T[] _options;
private readonly Func<T, string> _displayer;
public IEnumerable<string> Options => _options.Select(_displayer);
public string Query { get; }
public Task HandleResult(int index) => _resultHandler(_options[index]);
public ChoiceResponse(string query, IEnumerable<T> choice, Func<T, Task> resultHandler, Func<T, string> display) {
Query = query;
_resultHandler = resultHandler;
_options = choice.ToArray();
_displayer = display;
}
}
}

View File

@ -0,0 +1,153 @@
using Discord.WebSocket;
using MinecraftDiscordBot.Commands;
using MinecraftDiscordBot.Models;
using System.Text;
namespace MinecraftDiscordBot.Services;
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)
=> 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 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";
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<bool> CraftItem(string itemid, int amount, CancellationToken ct) => await Method(CmdCraftItem, RootCommandService.Deserialize<bool>(), ct, new() {
["name"] = itemid,
["count"] = amount
});
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) {
var amount = 1;
string itemid;
if (parameters.Length is 1 or 2) {
itemid = parameters[0];
if (parameters.Length is 2)
if (int.TryParse(parameters[1], out var value)) amount = value;
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();
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(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,77 @@
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) : 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)
=> 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);
}