global using Discord; global using Discord.WebSocket; using Discord.Interactions; using Microsoft.Extensions.DependencyInjection; using System.Reflection; using System.Text; namespace WorldTime; /// /// Main class for the program. Configures the client on start and occasionally prints status information. /// internal class WorldTime : IDisposable { /// /// Number of seconds between each time the status task runs, in seconds. /// #if DEBUG private const int StatusInterval = 20; #else private const int StatusInterval = 300; #endif /// /// Number of concurrent shard startups to happen on each check. /// This value is also used in . /// public const int MaxConcurrentOperations = 5; private readonly Task _statusTask; private readonly CancellationTokenSource _mainCancel; private readonly CommandsText _commandsTxt; private readonly IServiceProvider _services; internal Configuration Config { get; } internal DiscordShardedClient DiscordClient => _services.GetRequiredService(); internal Database Database => _services.GetRequiredService(); public WorldTime(Configuration cfg, Database d) { var ver = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version; Program.Log(nameof(WorldTime), $"Version {ver!.ToString(3)} is starting..."); Config = cfg; // Configure client, set up command handling var clientConf = new DiscordSocketConfig() { LogLevel = LogSeverity.Info, DefaultRetryMode = RetryMode.RetryRatelimit, MessageCacheSize = 0, // disable message cache GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildMembers | GatewayIntents.GuildMessages }; _services = new ServiceCollection() .AddSingleton(new DiscordShardedClient(clientConf)) .AddSingleton(s => new InteractionService(s.GetRequiredService())) .AddSingleton(d) .BuildServiceProvider(); DiscordClient.Log += DiscordClient_Log; DiscordClient.ShardReady += DiscordClient_ShardReady; DiscordClient.MessageReceived += DiscordClient_MessageReceived; var iasrv = _services.GetRequiredService(); DiscordClient.InteractionCreated += DiscordClient_InteractionCreated; iasrv.SlashCommandExecuted += InteractionService_SlashCommandExecuted; _commandsTxt = new CommandsText(this, Database); // Start status reporting thread _mainCancel = new CancellationTokenSource(); _statusTask = Task.Factory.StartNew(StatusLoop, _mainCancel.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default); } public async Task StartAsync() { await _services.GetRequiredService().AddModulesAsync(Assembly.GetExecutingAssembly(), _services); await DiscordClient.LoginAsync(TokenType.Bot, Config.BotToken).ConfigureAwait(false); await DiscordClient.StartAsync().ConfigureAwait(false); } public void Dispose() { _mainCancel.Cancel(); _statusTask.Wait(10000); if (!_statusTask.IsCompleted) Program.Log(nameof(WorldTime), "Warning: Main thread did not cleanly finish up in time. Continuing..."); _mainCancel.Cancel(); _statusTask.Wait(5000); _mainCancel.Dispose(); Program.Log(nameof(WorldTime), $"Uptime: {Program.BotUptime}"); } private async Task StatusLoop() { try { await Task.Delay(30000, _mainCancel.Token).ConfigureAwait(false); // initial 30 second delay while (!_mainCancel.IsCancellationRequested) { Program.Log(nameof(WorldTime), $"Bot uptime: {Program.BotUptime}"); await PeriodicReport(DiscordClient.CurrentUser.Id, DiscordClient.Guilds.Count, _mainCancel.Token).ConfigureAwait(false); await Task.Delay(StatusInterval * 1000, _mainCancel.Token).ConfigureAwait(false); } } catch (TaskCanceledException) { } } private static readonly HttpClient _httpClient = new(); /// /// Called by the status loop. Reports guild count to the console and to external services. /// private async Task PeriodicReport(ulong botId, int guildCount, CancellationToken cancellationToken) { var avg = (float)guildCount / Config.ShardTotal; Program.Log("Report", $"Currently in {guildCount} guilds. Average shard load: {avg:0.0}."); if (botId == 0) return; // Discord Bots if (!string.IsNullOrEmpty(Config.DBotsToken)) { try { string dBotsApiUrl = $"https://discord.bots.gg/api/v1/bots/{ botId }/stats"; var body = $"{{ \"guildCount\": {guildCount} }}"; var uri = new Uri(string.Format(dBotsApiUrl)); var post = new HttpRequestMessage(HttpMethod.Post, uri); post.Headers.Add("Authorization", Config.DBotsToken); post.Content = new StringContent(body, Encoding.UTF8, "application/json"); await _httpClient.SendAsync(post, cancellationToken); Program.Log("Discord Bots", "Update successful."); } catch (Exception ex) { Program.Log("Discord Bots", "Exception encountered during update: " + ex.Message); } } } #region Event handling private Task DiscordClient_Log(LogMessage arg) { // Suppress certain messages if (arg.Message != null) { // These warnings appear often as of Discord.Net v3... if (arg.Message.StartsWith("Unknown Dispatch ") || arg.Message.StartsWith("Unknown Channel")) return Task.CompletedTask; switch (arg.Message) // Connection status messages replaced by ShardManager's output { case "Connecting": case "Connected": case "Ready": case "Disconnecting": case "Disconnected": case "Resumed previous session": case "Failed to resume previous session": case "Discord.WebSocket.GatewayReconnectException: Server requested a reconnect": return Task.CompletedTask; } Program.Log("Discord.Net", $"{arg.Severity}: {arg.Message}"); } // Suppress certain exceptions if (arg.Exception != null) { if (arg.Exception is not GatewayReconnectException) Program.Log("Discord.Net exception", arg.Exception.ToString()); } return Task.CompletedTask; } private async Task DiscordClient_ShardReady(DiscordSocketClient arg) { // TODO get rid of this eventually? or change it to something fun... await arg.SetGameAsync("/help"); #if !DEBUG // Update slash/interaction commands if (arg.ShardId == 0) { await _services.GetRequiredService().RegisterCommandsGloballyAsync(); Program.Log("Command registration", "Updated global command registration."); } #else // Debug: Register our commands locally instead, in each guild we're in var iasrv = _services.GetRequiredService(); foreach (var g in arg.Guilds) { await iasrv.RegisterCommandsToGuildAsync(g.Id, true).ConfigureAwait(false); Program.Log("Command registration", $"Updated DEBUG command registration in guild {g.Id}."); } #endif } /// /// Non-specific handler for incoming events. /// private async Task DiscordClient_MessageReceived(SocketMessage message) { if (message.Author.IsWebhook) return; if (message.Type != MessageType.Default) return; if (message.Channel is not SocketTextChannel channel) return; /* * From https://support-dev.discord.com/hc/en-us/articles/4404772028055: * "You will still receive the events and can call the same APIs, and you'll get other data about a message like * author and timestamp. To put it simply, you'll be able to know all the information about when someone sends a * message; you just won't know what they said." * * Assuming this stays true, it will be possible to maintain legacy behavior after this bot loses full access to incoming messages. */ // Attempt to update user's last_seen column // POTENTIAL BUG: If user does a list command, the list may be processed before their own time's refreshed, and they may be skipped. var hasMemberHint = await Database.UpdateLastActivityAsync((SocketGuildUser)message.Author).ConfigureAwait(false); // Proactively fill guild user cache if the bot has any data for the respective guild // Can skip an extra query if the last_seen update is known to have been successful, otherwise query for any users var guild = channel.Guild; if (!guild.HasAllMembers && (hasMemberHint || await Database.HasAnyAsync(guild).ConfigureAwait(false))) { // Event handler hangs if awaited normally or used with Task.Run await Task.Factory.StartNew(guild.DownloadUsersAsync).ConfigureAwait(false); } } const string InternalError = ":x: An unknown error occurred. If it persists, please notify the bot owner."; // Slash command preparation and invocation private async Task DiscordClient_InteractionCreated(SocketInteraction arg) { var context = new ShardedInteractionContext(DiscordClient, arg); try { await _services.GetRequiredService().ExecuteCommandAsync(context, _services); } catch (Exception ex) { Program.Log(nameof(DiscordClient_InteractionCreated), $"Unhandled exception. {ex}"); if (arg.Type == InteractionType.ApplicationCommand) { if (arg.HasResponded) await arg.ModifyOriginalResponseAsync(prop => prop.Content = InternalError); else await arg.RespondAsync(InternalError); } } } // Slash command logging and failed execution handling private static async Task InteractionService_SlashCommandExecuted(SlashCommandInfo info, IInteractionContext context, IResult result) { string sender; if (context.Guild != null) { sender = $"{context.Guild}!{context.User}"; } else { sender = $"{context.User} in non-guild context"; } var logresult = $"{(result.IsSuccess ? "Success" : "Fail")}: `/{info}` by {sender}."; if (result.Error != null) { // Additional log information with error detail logresult += " " + Enum.GetName(typeof(InteractionCommandError), result.Error) + ": " + result.ErrorReason; // Specific responses to errors, if necessary if (result.Error == InteractionCommandError.UnmetPrecondition) { string errReply = result.ErrorReason switch { RequireGuildContextAttribute.Error => RequireGuildContextAttribute.Reply, _ => result.ErrorReason }; await context.Interaction.RespondAsync(errReply, ephemeral: true); } else { // Generic error response // TODO when implementing proper application error logging, see here var ia = context.Interaction; if (ia.HasResponded) await ia.ModifyOriginalResponseAsync(p => p.Content = InternalError); else await ia.RespondAsync(InternalError); } } Program.Log("Command", logresult); } #endregion }