using CommandLine; using Discord; using Discord.Commands; using Discord.Rest; using Discord.WebSocket; using Fleck; using Newtonsoft.Json; using System.Collections.Concurrent; using System.Reflection; using System.Runtime.CompilerServices; namespace MinecraftDiscordBot; public class Program : IDisposable { public const string WebSocketSource = "WebSocket"; public const string BotSource = "Bot"; private static readonly object LogLock = new(); private readonly DiscordSocketClient _client = new(new() { LogLevel = LogSeverity.Verbose, GatewayIntents = GatewayIntents.AllUnprivileged & ~(GatewayIntents.GuildScheduledEvents | GatewayIntents.GuildInvites) }); private readonly WebSocketServer _wssv; private readonly BotConfiguration _config; private readonly HashSet _whitelistedChannels; private readonly ConcurrentDictionary _connections = new(); private static readonly char[] WhiteSpace = new char[] { '\t', '\n', ' ', '\r' }; public ITextChannel[] _channels = Array.Empty(); private RefinedStorageComputer? _rsSystem = null; private bool disposedValue; public RefinedStorageComputer? RsSystem { get => _rsSystem; set { if (_rsSystem != value) { _rsSystem = value; _ = Task.Run(() => Broadcast(i => i.SendMessageAsync(value is null ? $"The Refined Storage went offline. Please check the server!" : $"The Refined Storage is back online!"))); } } } private async Task Broadcast(Func> message) => _ = await Task.WhenAll(_channels.Select(message)); public Program(BotConfiguration config) { _config = config; _client.Log += LogAsync; _client.MessageReceived += (msg) => DiscordMessageReceived(msg); _wssv = new WebSocketServer($"ws://0.0.0.0:{config.Port}") { RestartAfterListenError = true }; FleckLog.LogAction = LogWebSocket; _whitelistedChannels = config.Channels.ToHashSet(); } private void LogWebSocket(LogLevel level, string message, Exception exception) => Log(new(level switch { LogLevel.Debug => LogSeverity.Debug, LogLevel.Info => LogSeverity.Info, LogLevel.Warn => LogSeverity.Warning, LogLevel.Error => LogSeverity.Error, _ => LogSeverity.Critical // Unknown logging states should behave critical }, WebSocketSource, message, exception)); public static Task Main(string[] args) => Parser.Default.ParseArguments(args) .MapResult>( RunWithConfig, RunWithConfig, errs => Task.FromResult(1)); private static Task RunWithConfig(IBotConfigurator arg) => new Program(arg.Config).RunAsync(); public async Task RunAsync() { await _client.LoginAsync(TokenType.Bot, _config.Token); await _client.StartAsync(); if (!await HasValidChannels()) return 1; StartWebSocketServer(); // Block this task until the program is closed. await Task.Delay(-1); return 0; } private async Task HasValidChannels() { if (await GetValidChannels(_whitelistedChannels).ToArrayAsync() is not { Length: > 0 } channels) { await LogError(BotSource, new InvalidOperationException("No valid textchannel was whitelisted!")); return false; } _channels = channels; return true; } private void StartWebSocketServer() => _wssv.Start(socket => { socket.OnOpen = async () => await SocketOpened(socket); socket.OnClose = async () => await SocketClosed(socket); socket.OnMessage = async message => await SocketReceived(socket, message); }); private async IAsyncEnumerable GetValidChannels(IEnumerable ids) { foreach (var channelId in ids) { var channel = await _client.GetChannelAsync(channelId); if (channel is not ITextChannel textChannel) { if (channel is null) await LogWarning(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}]!"); continue; } if (textChannel.Guild is RestGuild guild) { await guild.UpdateAsync(); await LogInfo(BotSource, $"Whitelisted in channel: {channel.Name} [{channel.Id}] on server {guild.Name} [{guild.Id}]"); } else { await LogWarning(BotSource, $"Whitelisted in channel: {channel.Name} [{channel.Id}] on unknown server!"); } yield return textChannel; } } private async Task SocketReceived(IWebSocketConnection socket, string message) { if (JsonConvert.DeserializeObject(message) is not CapabilityMessage capability) return; try { var pc = capability.Role switch { RefinedStorageComputer.Role => new RefinedStorageComputer(socket), string role => throw new ArgumentException($"Invalid role '{role}'!") }; AddComputerSocket(socket, pc); await LogInfo(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Presented capability as {pc.GetType().Name}"); } catch (ArgumentException e) { await LogError(WebSocketSource, e); } } private void AddComputerSocket(IWebSocketConnection socket, ConnectedComputer pc) { if (pc is RefinedStorageComputer rs) RsSystem = rs; } private void RemoveComputerSocket(IWebSocketConnection socket) { if (RsSystem?.ConnectionInfo.Id == socket.ConnectionInfo.Id) RsSystem = null; } private async Task SocketClosed(IWebSocketConnection socket) { RemoveComputerSocket(socket); await LogInfo(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Client disconnected!"); } private static async Task SocketOpened(IWebSocketConnection socket) => await LogInfo(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Client connected from {socket.ConnectionInfo.ClientIpAddress}:{socket.ConnectionInfo.ClientPort}!"); private async Task DiscordMessageReceived(SocketMessage arg, int timeout = 10000) { if (arg is not SocketUserMessage message) return; if (message.Author.IsBot) return; if (!IsChannelWhitelisted(arg.Channel)) return; var cts = new CancellationTokenSource(timeout); if (IsCommand(message, out var argPos)) { var parameters = message.Content[argPos..].Split(WhiteSpace, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); _ = Task.Run(() => HandleCommand(message, parameters, cts.Token)); return; } await LogInfo("Discord", $"[{arg.Author.Username}] {arg.Content}"); // TODO: Relay Message to Chat Receiver } private Task HandleCommand(SocketUserMessage message, string[] parameters, CancellationToken ct) => parameters is { Length: > 0 } ? parameters[0].ToLower() switch { RefinedStorageComputer.Role => HandleRefinedStorageCommand(message, parameters[1..], ct), _ => 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) => RsSystem is RefinedStorageComputer rs ? rs.HandleCommand(message, parameters, ct) : message.ReplyAsync("The Refined Storage system is currently unavailable!"); private bool IsCommand(SocketUserMessage message, out int argPos) { argPos = 0; return message.HasStringPrefix(_config.Prefix, ref argPos); } private bool IsChannelWhitelisted(ISocketMessageChannel channel) => _whitelistedChannels.Contains(channel.Id); public static ConfiguredTaskAwaitable LogInfo(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 LogError(string source, Exception exception) => LogAsync(new(LogSeverity.Error, source, exception?.Message, exception)).ConfigureAwait(false); private static async Task LogAsync(LogMessage msg) { Log(msg); await Task.CompletedTask; } private static void Log(LogMessage msg) { lock (LogLock) Console.WriteLine(msg.ToString()); } protected virtual void Dispose(bool disposing) { if (!disposedValue) { if (disposing) { // TODO: dispose managed state (managed objects) _wssv.Dispose(); _client.Dispose(); } // TODO: free unmanaged resources (unmanaged objects) and override finalizer // TODO: set large fields to null disposedValue = true; } } // // TODO: override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources // ~Program() // { // // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method // Dispose(disposing: false); // } public void Dispose() { // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method Dispose(disposing: true); GC.SuppressFinalize(this); } }