Compare commits

...

11 Commits
1.1.1 ... main

Author SHA1 Message Date
0e09aaef5c
Bumped dependencies 2022-11-30 23:33:48 +01:00
29b8c59c9e
Automatically get version number from project file 2022-11-23 00:35:14 +01:00
bbdd5ce586
Bump version to 1.1.4
Added build script to solution files
Added address to configure client connection destination (if proxied)
Added automatic semantic versioning build
2022-11-23 00:28:01 +01:00
Michael Chen
6920d1a2b3
Bump version to 1.1.3
Added player status events
Added debugger displays for messages
2022-01-18 16:48:44 +01:00
Michael Chen
82c8313cb9
Added peripheral list 2022-01-18 16:01:45 +01:00
Michael Chen
a6ee52f70e
Log to discord log channel 2022-01-18 13:05:34 +01:00
Michael Chen
735bc8e8ae
Bump version to 1.1.2
Cleaner item list display
Allow full item list as file
Fixed online status message with new handler
2022-01-18 11:46:23 +01:00
Michael Chen
a55af9f667
Increase chunk size to send fewer messages.
Fixed logging lock (causes color issues in parallel env)
2022-01-18 10:43:15 +01:00
Michael Chen
f912b9db8f
Re-added chat relaying
Fixed async naming scheme
Added webhook for every channel
Added colorful logging
2022-01-18 10:10:49 +01:00
Michael Chen
e7b056342f
Added player detector implementation
Added computer event listeners
2022-01-17 19:10:14 +01:00
Michael Chen
92aafcde70
Smarter message parsing
Future: make return params dynamic, not string
now requires type in message objects
2022-01-17 16:23:34 +01:00
17 changed files with 597 additions and 144 deletions

View File

@ -6,6 +6,7 @@ MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{CE65C879-794A-4695-B659-7376FE7DB5E3}"
ProjectSection(SolutionItems) = preProject
.gitignore = .gitignore
build.py = build.py
MinecraftDiscordBot\bin\Debug\net6.0\config.json = MinecraftDiscordBot\bin\Debug\net6.0\config.json
EndProjectSection
EndProject

View File

@ -11,8 +11,11 @@ public class BotConfiguration : IBotConfiguration, IBotConfigurator {
[JsonProperty("token", Required = Required.Always)]
[Option('t', "token", HelpText = "The Discord bot token", Required = true)]
public string Token { get; init; } = default!;
[JsonProperty("address", Required = Required.Always)]
[Option('a', "address", HelpText = "The connection string for the websocket", Required = true)]
public string Address { get; init; } = default!;
[JsonProperty("port", Required = Required.DisallowNull)]
[Option('p', "port", Default = DEFAULT_PORT, HelpText = "The websocket server port")]
[Option('p', "port", Default = DEFAULT_PORT, HelpText = "The websocket server listen port")]
public int Port { get; init; } = DEFAULT_PORT;
[JsonProperty("channels", Required = Required.Always)]
[Option('c', "channel", HelpText = "The list of whitelisted channels", Required = true, Min = 1)]
@ -26,6 +29,9 @@ public class BotConfiguration : IBotConfiguration, IBotConfigurator {
[JsonProperty("admins", Required = Required.DisallowNull)]
[Option("admins", Default = new ulong[] { }, HelpText = "The list of bot administrators.")]
public ulong[] Administrators { get; init; } = Array.Empty<ulong>();
[JsonProperty("logchannel", Required = Required.DisallowNull)]
[Option("logchannel", Default = null, HelpText = "Optionally the id of a channel to mirror log to.")]
public ulong? LogChannel { get; init; } = null;
[JsonIgnore]
public BotConfiguration Config => this;
}

View File

@ -1,9 +1,12 @@
local secretToken = "$TOKEN"
local connectionUri = "$HOST"
local waitSeconds = 5
-- https://github.com/cc-tweaked/CC-Tweaked/blob/9cf70b10effeeed23e0e9c537bbbe0b2ff0d1a0f/src/main/java/dan200/computercraft/core/apis/http/options/AddressRule.java#L29
-- Chunk size must be less than packet size (type reply, success, chunkids, content: chunk) 16 kb for buffer
local maxMessageSize = (128 - 16) * 1024
local function chunkString(value, chunkSize)
if not chunkSize then chunkSize = 10000 end
if not chunkSize then chunkSize = maxMessageSize end
local length = value:len()
local total = math.ceil(length / chunkSize)
local chunks = {}
@ -29,7 +32,7 @@ local function sendResponse(socket, id, result, success)
local total, chunks = chunkString(result)
for i, chunk in pairs(chunks) do
sendJson(socket, { id = id, result = chunk, chunk = i, total = total, success = success })
sendJson(socket, { type = "reply", id = id, result = chunk, chunk = i, total = total, success = success })
end
end
@ -54,6 +57,16 @@ local function runRsCommand(params)
return textutils.serializeJSON(retvals)
end
local function getPeripheralInfo(side)
return {type = peripheral.getType(side), methods = peripheral.getMethods(side), side = side}
end
local function getPeripheralList()
local pers = {}
for i,side in pairs(peripheral.getNames()) do pers[side] = getPeripheralInfo(side) end
return pers
end
-- error: any error during execution
-- return string result
local function getResponse(parsed)
@ -73,6 +86,21 @@ local function getResponse(parsed)
return textutils.serializeJSON(item)
elseif parsed.method == "command" then
return runRsCommand(parsed.params)
elseif parsed.method == "peripherals" then
return textutils.serializeJSON(getPeripheralList())
elseif parsed.method == "getonline" then
return textutils.serializeJSON(getPeripheral("playerDetector").getOnlinePlayers())
elseif parsed.method == "whereis" then
local pos = getPeripheral("playerDetector").getPlayerPos(parsed.params.username)
if not pos then return "null" end
return textutils.serializeJSON(pos)
elseif parsed.method == "send" then
if not parsed.params.username then
getPeripheral("chatBox").sendMessage(parsed.params.message, parsed.params.prefix)
else
getPeripheral("chatBox").sendMessageToPlayer(parsed.params.message, parsed.params.username, parsed.params.prefix)
end
return "true"
end
error({message = "No message handler for method: "..parsed.method.."!"})
@ -119,13 +147,7 @@ local function handleMessage(socket, message)
return false
end
local function socketClient()
print("Connecting to the socket server at "..connectionUri.."...")
local socket, reason = http.websocket(connectionUri)
if not socket then error("Socket server could not be reached: "..reason) end
print("Connection successful!")
socket.send("login="..secretToken)
local function responder(socket)
while true do
local message, binary = socket.receive()
if not not message and not binary then
@ -142,15 +164,98 @@ local function termWaiter()
os.pullEvent("terminate")
end
local function services()
parallel.waitForAny(termWaiter, function()
parallel.waitForAll(socketClient)
end)
local function chatEventListener(socket)
while true do
event, username, message, uuid, hidden = os.pullEvent("chat")
sendJson(socket, {type = "chat", username = username, message = message, uuid = uuid, hidden = hidden})
print("Chat event relayed!")
end
end
local function peripheralDetachEventListener(socket)
while true do
event, side = os.pullEvent("peripheral_detach")
sendJson(socket, {type = "peripheral_detach", side = side})
print("Peripheral was detached!")
end
end
local function peripheralAttachEventListener(socket)
while true do
event, side = os.pullEvent("peripheral")
sendJson(socket, {type = "peripheral", peripheral = getPeripheralInfo(side) })
print("Peripheral was attached!")
end
end
local function listAsSet(list)
local asSet = {}
for i,elem in pairs(list) do asSet[elem] = true end
return asSet
end
local function joinSets(a, b)
local joined = {}
for elem,exists in pairs(a) do if exists then joined[elem] = true end end
for elem,exists in pairs(b) do if exists then joined[elem] = true end end
return joined
end
local function playerStatusEventListener(socket)
local players = {}
while true do
local pd = peripheral.find("playerDetector")
if not not pd then
players = listAsSet(pd.getOnlinePlayers())
break
end
printError("playerDetector not connected!")
sleep(5)
end
while true do
local pd = peripheral.find("playerDetector")
if not not pd then
local newPlayers = listAsSet(pd.getOnlinePlayers())
for player,_ in pairs(joinSets(players, newPlayers)) do
if players[player] and (not newPlayers[player]) then
sendJson(socket, {type = "playerstatus", player = player, status = false})
elseif (not players[player]) and newPlayers[player] then
sendJson(socket, {type = "playerstatus", player = player, status = true})
end
end
players = newPlayers
end
sleep(1)
end
end
local function eventListeners(socket)
parallel.waitForAny(
termWaiter,
function() chatEventListener(socket) end,
function() playerStatusEventListener(socket) end,
function() peripheralDetachEventListener(socket) end,
function() peripheralAttachEventListener(socket) end
)
end
local function socketClient()
print("Connecting to the socket server at "..connectionUri.."...")
local socket, reason = http.websocket(connectionUri)
if not socket then error("Socket server could not be reached: "..reason) end
print("Connection successful!")
socket.send("login="..secretToken)
parallel.waitForAny(
function() responder(socket) end,
function() eventListeners(socket) end
)
socket.close()
end
local function main()
while true do
local status, error = pcall(services)
local status, error = pcall(socketClient)
if status then break end
printError("An uncaught exception was raised:", error)
printError("Restarting in", waitSeconds, "seconds...")

View File

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

View File

@ -6,7 +6,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<Version>1.1.1</Version>
<Version>1.1.5</Version>
<Authors>Michael Chen</Authors>
<Company>$(Authors)</Company>
<RepositoryUrl>https://gitlab.com/chenmichael/mcdiscordbot</RepositoryUrl>
@ -20,11 +20,11 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.8.0" />
<PackageReference Include="Discord.Net" Version="3.1.0" />
<PackageReference Include="CommandLineParser" Version="2.9.1" />
<PackageReference Include="Discord.Net" Version="3.8.1" />
<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="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.17.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.2" />
</ItemGroup>
<ItemGroup>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,9 +2,11 @@
using Discord;
using Discord.Commands;
using Discord.Rest;
using Discord.Webhook;
using Discord.WebSocket;
using Fleck;
using MinecraftDiscordBot.Commands;
using MinecraftDiscordBot.Models;
using MinecraftDiscordBot.Services;
using System.Collections.Concurrent;
using System.Reflection;
@ -26,11 +28,16 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
private readonly HashSet<ulong> _whitelistedChannels;
private readonly ConcurrentDictionary<Guid, RootCommandService> _connections = new();
private static readonly char[] WhiteSpace = new char[] { '\t', '\n', ' ', '\r' };
public ITextChannel[] _channels = Array.Empty<ITextChannel>();
private RootCommandService? _rsSystem = null;
public IEnumerable<ActiveChannel> Channels => _channels ?? throw new InvalidProgramException("Channels used before verification!");
public ActiveChannel[]? _channels;
private bool disposedValue;
private static ITextChannel? LogChannel;
private readonly RootCommandService _computer;
public static bool OnlineNotifications => true;
public const LogSeverity DiscordLogSeverity = LogSeverity.Warning;
private const string ClientScriptName = "MinecraftDiscordBot.ClientScript.lua";
private const string WebhookName = "minecraftbot";
public readonly string ClientScript;
private readonly ITokenProvider _tokenProvider = new TimeoutTokenProvider(InstanceId, 10);
private static readonly int InstanceId = new Random().Next();
@ -43,28 +50,23 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
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}");
.Replace("$HOST", config.Address);
}
public RootCommandService? Computer {
get => _rsSystem; set {
if (_rsSystem != value) {
_rsSystem = value;
if (OnlineNotifications)
_ = Task.Run(() => Broadcast(i => i.SendMessageAsync(value is null
? $"The Minecraft client has gone offline!"
: $"The Minecraft client is now online!")));
}
}
}
private async Task Broadcast(Func<ITextChannel, Task<IUserMessage>> message) => _ = await Task.WhenAll(_channels.Select(message));
private Task Broadcast(Func<ITextChannel, Task<IUserMessage>> message)
=> Task.WhenAll(Channels.Select(i => message(i.Channel)));
public Program(BotConfiguration config) {
_config = config;
_computer = new(this);
_computer.ChatMessageReceived += MinecraftMessageReceived;
_computer.SocketChanged += ComputerConnectedChanged;
_computer.PlayerStatusChanged += PlayerStatusChanged;
_computer.PeripheralAttached += PeripheralAttached;
_computer.PeripheralDetached += PeripheralDetached;
_administrators = config.Administrators.ToHashSet();
ClientScript = GetClientScript(config);
_client.Log += LogAsync;
_client.MessageReceived += (msg) => DiscordMessageReceived(msg);
_client.MessageReceived += (msg) => DiscordMessageReceived(msg, 20 * 1000);
_client.ReactionAdded += DiscordReactionAdded;
_wssv = new WebSocketServer($"ws://0.0.0.0:{config.Port}") {
RestartAfterListenError = true
@ -73,6 +75,17 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
_whitelistedChannels = config.Channels.ToHashSet();
}
private void PlayerStatusChanged(object? sender, PlayerStatusEvent e)
=> _ = Task.Run(() => Broadcast(i => i.SendMessageAsync($"{e.Player} just {(e.Online ? "joined" : "left")} the server!")));
private void PeripheralAttached(object? sender, PeripheralAttachEvent e) => LogInfo("Computer", $"Peripheral {e.Peripheral.Type} was attached on side {e.Side}.");
private void PeripheralDetached(object? sender, PeripheralDetachEvent e) => LogInfo("Computer", $"Peripheral on side {e.Side} was detached.");
private void ComputerConnectedChanged(object? sender, IWebSocketConnection? e)
=> _ = Task.Run(() => Broadcast(i => i.SendMessageAsync(e is not null
? "The Minecraft client is now available!"
: "The Minecraft client disconnected!")));
private void MinecraftMessageReceived(object? sender, ChatEvent e)
=> Task.Run(() => WebhookBroadcast(i => i.SendMessageAsync(e.Message, username: e.Username, avatarUrl: $"https://crafatar.com/renders/head/{e.UUID}")));
private Task<T[]> WebhookBroadcast<T>(Func<DiscordWebhookClient, Task<T>> apply) => Task.WhenAll(Channels.Select(i => apply(new DiscordWebhookClient(i.Webhook))));
private void LogWebSocket(LogLevel level, string message, Exception exception) => Log(new(level switch {
LogLevel.Debug => LogSeverity.Debug,
LogLevel.Info => LogSeverity.Info,
@ -104,11 +117,20 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
}
private async Task<bool> HasValidChannels() {
if (await GetValidChannels(_whitelistedChannels).ToArrayAsync() is not { Length: > 0 } channels) {
if (_config.LogChannel is ulong logChannelId) {
LogChannel = await IsValidChannel(logChannelId);
if (LogChannel is null)
await LogWarningAsync(BotSource, $"The given log channel ID is not valid '{logChannelId}'!");
}
if (await GetValidChannels(_whitelistedChannels) is not { Length: > 0 } channels) {
await LogErrorAsync(BotSource, new InvalidOperationException("No valid textchannel was whitelisted!"));
return false;
}
_channels = channels;
_channels = await Task.WhenAll(channels.Select(async i => new ActiveChannel(i, await GetOrCreateWebhook(i))));
static async Task<IWebhook> GetOrCreateWebhook(ITextChannel i) {
var hooks = (await i.GetWebhooksAsync()).Where(i => i.Name == WebhookName).FirstOrDefault();
return hooks ?? await i.CreateWebhookAsync(WebhookName);
}
return true;
}
@ -118,23 +140,23 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
socket.OnMessage = async message => await SocketReceived(socket, message);
});
private async IAsyncEnumerable<ITextChannel> GetValidChannels(IEnumerable<ulong> ids) {
foreach (var channelId in ids) {
var channel = await _client.GetChannelAsync(channelId);
if (channel is not ITextChannel textChannel) {
if (channel is null) await LogWarningAsync(BotSource, $"Channel with id [{channelId}] does not exist!");
else await LogWarningAsync(BotSource, $"Channel is not a text channels and will not be used: {channel.Name} [{channel.Id}]!");
continue;
}
if (textChannel.Guild is RestGuild guild) {
await guild.UpdateAsync();
await LogInfoAsync(BotSource, $"Whitelisted in channel: {channel.Name} [{channel.Id}] on server {guild.Name} [{guild.Id}]");
} else {
await LogWarningAsync(BotSource, $"Whitelisted in channel: {channel.Name} [{channel.Id}] on unknown server!");
}
yield return textChannel;
private async Task<ITextChannel[]> GetValidChannels(IEnumerable<ulong> ids)
=> (await Task.WhenAll(ids.Select(i => IsValidChannel(i)))).OfType<ITextChannel>().ToArray();
private async Task<ITextChannel?> IsValidChannel(ulong channelId) {
var channel = await _client.GetChannelAsync(channelId);
if (channel is not ITextChannel textChannel) {
if (channel is null) await LogWarningAsync(BotSource, $"Channel with id [{channelId}] does not exist!");
else await LogWarningAsync(BotSource, $"Channel is not a text channels and will not be used: {channel.Name} [{channel.Id}]!");
return null;
}
if (textChannel.Guild is RestGuild guild) {
await guild.UpdateAsync();
await LogInfoAsync(BotSource, $"Whitelisted in channel: {channel.Name} [{channel.Id}] on server {guild.Name} [{guild.Id}]");
} else {
await LogWarningAsync(BotSource, $"Whitelisted in channel: {channel.Name} [{channel.Id}] on unknown server!");
}
return textChannel;
}
private async Task SocketReceived(IWebSocketConnection socket, string message) {
@ -142,17 +164,21 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
await (message switch {
"getcode" => SendClientCode(socket),
string s when s.StartsWith("login=") => ClientComputerConnected(socket, s[6..]),
string s when s.StartsWith("error=") => ClientComputerError(socket, s[6..]),
_ => DisruptClientConnection(socket, "Protocol violation!")
});
}
private static async Task ClientComputerError(IWebSocketConnection socket, string message)
=> await LogWarningAsync("Client", $"Computer failed to run the script: {message}");
private async Task ClientComputerConnected(IWebSocketConnection socket, string token) {
if (!_tokenProvider.VerifyToken(token)) {
await DisruptClientConnection(socket, "outdated");
return;
}
await LogInfoAsync(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Client logged in with valid script!");
AddComputerSocket(socket, new(socket, this));
AddComputerSocket(socket);
}
private static async Task DisruptClientConnection(IWebSocketConnection socket, string reason) {
@ -166,10 +192,10 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
await LogInfoAsync(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Script sent to client!");
}
private void AddComputerSocket(IWebSocketConnection socket, RootCommandService pc) => Computer = pc;
private void AddComputerSocket(IWebSocketConnection socket) => _computer.Socket = socket;
private void RemoveComputerSocket(IWebSocketConnection socket) {
if (Computer is { ConnectionInfo.Id: Guid id } && id == socket.ConnectionInfo.Id) Computer = null;
if (_computer.Socket is { ConnectionInfo.Id: Guid id } && id == socket.ConnectionInfo.Id) _computer.Socket = null;
}
private async Task SocketClosed(IWebSocketConnection socket) {
@ -198,13 +224,14 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
}
await LogInfoAsync("Discord", $"[{arg.Author.Username}] {arg.Content}");
// TODO: Relay Message to Chat Receiver
_ = Task.Run(() => _computer.Chat.SendMessageAsync(arg.Content, arg.Author.Username, cts.Token));
}
private Task SendResponse(SocketUserMessage message, ResponseType response) => response switch {
ResponseType.IChoiceResponse res => HandleChoice(message, res),
ResponseType.StringResponse res => message.ReplyAsync(res.Message),
_ => message.ReplyAsync($"Whoops, someone forgot to implement '{response.GetType()}' responses?"),
ResponseType.FileResponse res => message.Channel.SendFileAsync(res.Path, text: res.Message),
_ => message.ReplyAsync($"Whoops, someone forgot to implement '{response.GetType().Name}' responses?"),
};
private readonly ConcurrentDictionary<ulong, ResponseType.IChoiceResponse> _choiceWait = new();
@ -232,7 +259,7 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
}
public async Task<ResponseType> HandleCommand(SocketUserMessage message, string[] parameters, CancellationToken ct) {
if (Computer is ICommandHandler<ResponseType> handler)
if (_computer is ICommandHandler<ResponseType> handler)
try {
return await handler.HandleCommand(message, parameters, ct);
} catch (TaskCanceledException) {
@ -262,14 +289,29 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
public static void LogError(string source, Exception exception) => Log(new(LogSeverity.Error, source, exception?.Message, exception));
private static async Task LogAsync(LogMessage msg) {
Log(msg);
await Task.CompletedTask;
lock (LogLock) {
var oldColor = Console.ForegroundColor;
try {
Console.ForegroundColor = msg.Severity switch {
LogSeverity.Critical => ConsoleColor.Magenta,
LogSeverity.Error => ConsoleColor.Red,
LogSeverity.Warning => ConsoleColor.Yellow,
LogSeverity.Info => ConsoleColor.White,
LogSeverity.Verbose => ConsoleColor.Blue,
LogSeverity.Debug => ConsoleColor.DarkBlue,
_ => ConsoleColor.Cyan,
};
Console.WriteLine(msg.ToString());
} finally {
Console.ForegroundColor = oldColor;
}
}
if (msg.Severity <= DiscordLogSeverity && LogChannel is ITextChannel log) {
await log.SendMessageAsync($"{msg.Severity}: {msg}");
}
}
public static void Log(LogMessage msg) {
lock (LogLock)
Console.WriteLine(msg.ToString());
}
public static void Log(LogMessage msg) => _ = Task.Run(() => LogAsync(msg));
protected virtual void Dispose(bool disposing) {
if (!disposedValue) {
@ -304,10 +346,22 @@ public class Program : IDisposable, ICommandHandler<ResponseType>, IUserRoleMana
}
}
public class ActiveChannel {
public ActiveChannel(ITextChannel channel, IWebhook webhook) {
Channel = channel;
Webhook = webhook;
}
public IWebhook Webhook { get; }
public ITextChannel Channel { get; }
}
public abstract class ResponseType {
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);
internal static ResponseType File(string path, string message) => new FileResponse(path, message);
public class StringResponse : ResponseType {
public StringResponse(string message) => Message = message;
public string Message { get; }
@ -331,4 +385,14 @@ public abstract class ResponseType {
_displayer = display;
}
}
public class FileResponse : ResponseType {
public FileResponse(string path, string message) {
Path = path;
Message = message;
}
public string Path { get; }
public string Message { get; }
}
}

View File

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

View File

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

View File

@ -1,4 +1,5 @@
using Discord.WebSocket;
using Discord;
using Discord.WebSocket;
using MinecraftDiscordBot.Commands;
using MinecraftDiscordBot.Models;
using System.Text;
@ -18,11 +19,8 @@ public class RefinedStorageService : CommandRouter {
public override Task<ResponseType> FallbackHandler(SocketUserMessage message, string method, string[] parameters, CancellationToken ct)
=> throw new ReplyException($"The RS system has no command '{method}'!");
private async Task<T> Method<T>(string methodName, Func<string, T> parser, CancellationToken ct, Dictionary<string, object>? parameters = null) {
var waiter = _taskSource.GetWaiter(parser, ct);
await _taskSource.Send(new RequestMessage(waiter.ID, methodName, parameters));
return await waiter.Task;
}
private Task<T> Method<T>(string methodName, Func<string, T> parser, CancellationToken ct, Dictionary<string, object>? parameters = null)
=> RootCommandService.Method(_taskSource, methodName, parser, ct, parameters);
private const string CmdEnergyUsage = "energyusage";
private const string CmdEnergyStorage = "energystorage";
@ -37,17 +35,17 @@ public class RefinedStorageService : CommandRouter {
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() {
public async Task<Item> GetItemDataAsync(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() {
public async Task<Item> GetItemDataAsync(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() {
public async Task<bool> CraftItemAsync(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() {
public async Task<LuaPackedArray> RawCommandAsync(string command, CancellationToken ct) => await Method(CmdCommand, LuaPackedArray.Deserialize, ct, new() {
["command"] = command
});
@ -94,7 +92,7 @@ public class RefinedStorageService : CommandRouter {
: 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)
return await CraftItemAsync(itemid, amount, ct)
? ResponseType.AsString($"Alright, I'm starting to craft {amount} {itemid}.")
: ResponseType.AsString($"Nope, that somehow doesn't work!");
}
@ -104,8 +102,8 @@ public class RefinedStorageService : CommandRouter {
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));
? GetItemDataAsync(fingerprint, ct)
: GetItemDataAsync(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) {
@ -143,12 +141,13 @@ public class RefinedStorageService : CommandRouter {
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);
var response = await RawCommandAsync(command, ct);
return ResponseType.AsString(response.ToString());
}
[CommandHandler(CmdListItems, HelpText = "Gets a list of items that are currently stored in the RS system.")]
public async Task<ResponseType> HandleItemListing(SocketUserMessage message, string[] parameters, CancellationToken ct) {
if (parameters.Length is 1 && parameters[0] == "full") return await SendFullItemList(message, ct);
var sb = new StringBuilder();
sb.Append("The Refined Storage system currently stores these items:");
var items = await RefreshItemList(ct);
@ -156,11 +155,29 @@ public class RefinedStorageService : CommandRouter {
var taken = 0;
foreach (var item in items) {
if (sb.Length > 500) break;
sb.AppendFormat("\n{0:n0}x {1}", item.Amount, item.DisplayName);
sb.Append('\n');
sb.Append(item.ToString());
taken++;
}
if (items.Count > taken) sb.AppendFormat("\nand {0} more items.", items.Skip(taken).Sum(i => i.Amount));
if (items.Count > taken) sb.AppendFormat("\nand {0:n0} more items.", items.Skip(taken).Sum(i => i.Amount));
}
return ResponseType.AsString(sb.ToString());
}
private async Task<ResponseType> SendFullItemList(SocketUserMessage message, CancellationToken ct) {
var path = await GetItemListFile(ct);
return ResponseType.File(path, $"{message.Author.Mention} Here you go:");
}
private async Task<string> GetItemListFile(CancellationToken ct) {
var items = await RefreshItemList(ct);
var file = Path.Combine(Path.GetTempPath(), "itemlist.txt");
var fs = new FileStream(file, FileMode.OpenOrCreate, FileAccess.Write);
using (var sw = new StreamWriter(fs, Encoding.UTF8)) {
await sw.WriteLineAsync("The RS System stores the following items:");
foreach (var item in items)
await sw.WriteLineAsync(item.DetailString);
};
return file;
}
}

View File

@ -3,6 +3,7 @@ using Fleck;
using MinecraftDiscordBot.Commands;
using MinecraftDiscordBot.Models;
using Newtonsoft.Json;
using System.Linq;
namespace MinecraftDiscordBot.Services;
@ -10,34 +11,77 @@ public delegate Task<TResponse> HandleCommandDelegate<TResponse>(SocketUserMessa
public delegate Task HandleCommandDelegate(SocketUserMessage message, string[] parameters, CancellationToken ct);
public class RootCommandService : CommandRouter, ITaskWaitSource {
protected readonly IWebSocketConnection _socket;
protected IWebSocketConnection? _socketField;
public override string HelpTextPrefix => "!";
public RootCommandService(IWebSocketConnection socket, IUserRoleManager roleManager) : base() {
socket.OnMessage = OnMessage;
_socket = socket;
_rs = new RefinedStorageService(this, roleManager);
public RootCommandService(IUserRoleManager roleManager) : base() {
RefinedStorage = new RefinedStorageService(this, roleManager);
Players = new PlayerDetectorService(this);
Chat = new ChatBoxService(this);
}
public static async Task<T> Method<T>(ITaskWaitSource taskSource, string methodName, Func<string, T> parser, CancellationToken ct, Dictionary<string, object>? parameters = null) {
var waiter = taskSource.GetWaiter(parser, ct);
await taskSource.Send(new RequestMessage(waiter.ID, methodName, parameters));
return await waiter.Task;
}
public event EventHandler<ChatEvent>? ChatMessageReceived;
public event EventHandler<PlayerStatusEvent>? PlayerStatusChanged;
public event EventHandler<PeripheralAttachEvent>? PeripheralAttached;
public event EventHandler<PeripheralDetachEvent>? PeripheralDetached;
public event EventHandler<IWebSocketConnection?>? SocketChanged;
public IWebSocketConnection? Socket {
get => _socketField; set {
if (_socketField != value) {
_socketField = value;
if (value is not null) value.OnMessage = OnMessage;
SocketChanged?.Invoke(this, value);
}
}
}
public RefinedStorageService RefinedStorage { get; }
public PlayerDetectorService Players { get; }
public ChatBoxService Chat { get; }
private void OnMessage(string message) {
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);
switch (Message.Deserialize(message)) {
case ChatEvent msg:
ChatMessageReceived?.Invoke(this, msg);
break;
case PlayerStatusEvent msg:
PlayerStatusChanged?.Invoke(this, msg);
break;
case PeripheralAttachEvent msg:
PeripheralAttached?.Invoke(this, msg);
break;
case PeripheralDetachEvent msg:
PeripheralDetached?.Invoke(this, msg);
break;
case ReplyMessage msg:
IChunkWaiter? waiter;
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);
break;
default:
Program.LogInfo(Program.WebSocketSource, $"Received unhandled message: {message}!");
break;
}
}
public Task Send(string message) => _socket.Send(message);
public Task Send(string message) => (Socket ?? throw new ReplyException("Minecraft server is not available!")).Send(message);
public Task Send(Message message) => Send(JsonConvert.SerializeObject(message));
private readonly object _syncRoot = new();
private readonly Dictionary<int, IChunkWaiter> _waits = new();
private readonly Random _rnd = new();
public IWebSocketConnectionInfo ConnectionInfo => _socket.ConnectionInfo;
private int GetFreeId() {
var attempts = 0;
@ -58,10 +102,19 @@ public class RootCommandService : CommandRouter, ITaskWaitSource {
return waiter;
}
private readonly ICommandHandler<ResponseType> _rs;
public Task<Dictionary<string, Peripheral>> GetPeripherals(CancellationToken ct) => Method(this, "peripherals", Deserialize<Dictionary<string, Peripheral>>(), ct);
[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);
=> RefinedStorage.HandleCommand(message, parameters, ct);
[CommandHandler("peripherals", HelpText = "Gets a list of peripherals that are attached.")]
public async Task<ResponseType> HandleGetPeripherals(SocketUserMessage message, string[] parameters, CancellationToken ct)
=> ResponseType.AsString(string.Join("\n", (await GetPeripherals(ct)).Values.Select(i => $"On side {i.Side}: {i.Type}")));
[CommandHandler("pd", HelpText = "Provides some commands for interacting with the Player Detector.")]
public Task<ResponseType> PlayerDetectorHandler(SocketUserMessage message, string[] parameters, CancellationToken ct)
=> Players.HandleCommand(message, parameters, ct);
[CommandHandler("chat", HelpText = "Provides some commands for chatting.")]
public Task<ResponseType> ChatBoxHandler(SocketUserMessage message, string[] parameters, CancellationToken ct)
=> Chat.HandleCommand(message, parameters, ct);
public static Func<string, T> Deserialize<T>() => msg
=> JsonConvert.DeserializeObject<T>(msg) ?? throw new InvalidProgramException("Empty response!");

View File

@ -2,11 +2,12 @@
import subprocess
import argparse
from itertools import chain
import re
dockercmd = 'docker'
parser = argparse.ArgumentParser(description='Create custom recumock images.')
parser.add_argument('tags', metavar='TAG', nargs='+', help='Version tags to build.')
parser.add_argument('tags', metavar='TAG', nargs='*', help='Version tags to build.')
args = parser.parse_args()
@ -15,20 +16,31 @@ platforms = ['linux/amd64', 'linux/arm64', 'linux/arm/v7']
def pull(image):
subprocess.run([dockercmd, 'pull', baseimage], check=True)
def build(image, directory, platforms, build_args = None):
def build(images, directory, platforms, build_args = None):
if build_args is None:
build_args = []
build_args = list(chain.from_iterable(['--build-arg', f'{arg}={val}'] for (arg, val) in build_args))
tags = list(chain.from_iterable(['-t', image] for image in images))
platformlist = ','.join(platforms)
subprocess.run([dockercmd, 'buildx', 'build', '-f', 'MinecraftDiscordBot/Dockerfile', '--platform', platformlist, '-t', image] + build_args + ['--push', directory], check=True)
command = [dockercmd, 'buildx', 'build', '-f', 'MinecraftDiscordBot/Dockerfile', '--platform', platformlist, *tags] + build_args + ['--push', directory]
print(' '.join(command))
subprocess.run(command, check=True)
for tag in args.tags:
targetimage = f'chenio/mcdiscordbot:{tag}'
baseimage = f'mcr.microsoft.com/dotnet/runtime:6.0'
#print(f'Pulling base image {baseimage}')
#pull(baseimage)
print(f'Building image {targetimage} from {baseimage}.')
build(targetimage, '.', platforms, [('TAG', tag)])
def version_from_project():
with open(r'MinecraftDiscordBot\MinecraftDiscordBot.csproj', 'r') as f:
project = f.read()
regex = r"<Version>\s*([^<]*?)\s*<\/Version>"
matches = re.search(regex, project, re.IGNORECASE)
if not matches:
raise Exception("Could not read version from project file!")
return matches.group(1)
if len(args.tags) == 0:
args.tags.append(version_from_project())
for version in args.tags:
parts = version.split('.')
tags = list('.'.join(parts[:i]) for i in range(1, len(parts) + 1))
tags.append('latest')
build([f'chenio/mcdiscordbot:{tag}' for tag in tags], '.', platforms)