// Copyright (C) 2021-present sam (starshines.gay) // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . using System.ComponentModel; using System.Diagnostics; using System.Runtime.InteropServices; using System.Text.Json; using System.Web; using Catalogger.Backend.Cache.InMemoryCache; using Catalogger.Backend.Extensions; using Catalogger.Backend.Services; using Humanizer; using Humanizer.Localisation; using Remora.Commands.Attributes; using Remora.Commands.Groups; using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.Commands.Extensions; using Remora.Discord.Commands.Feedback.Services; using Remora.Discord.Commands.Services; using Remora.Discord.Extensions.Embeds; using Remora.Results; using IClock = NodaTime.IClock; using IResult = Remora.Results.IResult; namespace Catalogger.Backend.Bot.Commands; [Group("catalogger")] [Description("Commands for information about the bot itself.")] public class MetaCommands( ILogger logger, IClock clock, Config config, ShardedGatewayClient client, IFeedbackService feedbackService, ContextInjectionService contextInjection, GuildCache guildCache, RoleCache roleCache, ChannelCache channelCache, EmojiCache emojiCache, IDiscordRestChannelAPI channelApi, PermissionResolverService permissionResolver ) : CommandGroup { private readonly ILogger _logger = logger.ForContext(); private readonly HttpClient _client = new(); [Command("help")] [Description("Learn more about Catalogger.")] public async Task HelpAsync() { var embed = new EmbedBuilder() .WithColour(DiscordUtils.Purple) .WithTitle("Catalogger") .WithDescription( """ A logging bot that integrates with PluralKit's message proxying. Use `/configure-channels` to get started! """ ); if (config.Discord.EnableDash) embed.Description += $"\n\nYou can also use the dashboard for configuration: {config.Web.BaseUrl}"; embed.AddField( "Configuration", """ `/configure-channels`: Set which events will be logged to which channels `/ignored-channels`: Set which channels will be ignored entirely `/redirects`: Override where a channel's messages will be logged `/key-roles`: Set which roles are treated as key roles and are logged with more detail than others `/invites`: Manage invites and create new ones `/check-permissions`: Check for any issues with logging """ ); embed.AddField("Creator", "<@694563574386786314> / starshines.gay"); embed.AddField( "Source code", "https://codeberg.org/starshine/catalogger / Licensed under the GNU AGPL v3" ); if (config.Discord.SupportGuild != null) embed.AddField( "Support", $"Use this link to join the support server: {config.Discord.SupportGuild}" ); return await feedbackService.ReplyAsync( embeds: [embed.Build().GetOrThrow()], isEphemeral: true ); } [Command("invite")] [Description("Get a link to invite Catalogger to your server.")] public async Task InviteAsync() { var inviteUrl = $"https://discord.com/oauth2/authorize?client_id={config.Discord.ApplicationId}" + "&permissions=537250993&scope=bot+applications.commands"; return await feedbackService.ReplyAsync( $"Use this link to invite Catalogger to your server: {inviteUrl}", isEphemeral: true ); } [Command("dashboard")] [Description("Get a link to the dashboard.")] public async Task DashboardLinkAsync() { if (!config.Discord.EnableDash) return await feedbackService.ReplyAsync( "The dashboard is not enabled for this version of Catalogger.", isEphemeral: true ); if ( contextInjection.Context?.TryGetGuildID(out var guildId) != true || contextInjection.Context?.TryGetUserID(out var userId) != true ) return await feedbackService.ReplyAsync( $"The dashboard is available here: {config.Web.BaseUrl}", isEphemeral: true ); var perms = await permissionResolver.GetGuildPermissionsAsync(guildId, userId); if ( perms.HasPermission(DiscordPermission.ManageGuild) || perms.HasPermission(DiscordPermission.Administrator) ) return await feedbackService.ReplyAsync( $"The dashboard for this server is available here: {config.Web.BaseUrl}/dash/{guildId}" ); return await feedbackService.ReplyAsync( $"The dashboard is available here: {config.Web.BaseUrl}", isEphemeral: true ); } [Command("ping")] [Description("Ping pong! See the bot's latency")] public async Task PingAsync() { var shardId = contextInjection.Context?.TryGetGuildID(out var guildId) == true ? client.ShardIdFor(guildId.Value) : 0; var averageLatency = client.Shards.Values.Select(x => x.Latency.TotalMilliseconds).Sum() / client.Shards.Count; var t1 = clock.GetCurrentInstant(); var msg = await feedbackService.SendContextualAsync("...").GetOrThrow(); var elapsed = clock.GetCurrentInstant() - t1; var process = Process.GetCurrentProcess(); var memoryUsage = process.WorkingSet64; var embed = new EmbedBuilder() .WithColour(DiscordUtils.Purple) .WithFooter( $"{RuntimeInformation.FrameworkDescription} on {RuntimeInformation.RuntimeIdentifier}" ) .WithCurrentTimestamp(); embed.AddField( "Ping", $"Gateway: {client.Shards[shardId].Latency.TotalMilliseconds:N0}ms (average: {averageLatency:N0}ms)\n" + $"API: {elapsed.TotalMilliseconds:N0}ms", inline: true ); embed.AddField("Memory usage", memoryUsage.Bytes().Humanize(), inline: true); var messageRate = await MessagesRate(); embed.AddField( "Messages received", messageRate != null ? $"{messageRate / 5:F1}/m\n({CataloggerMetrics.MessagesReceived.Value:N0} since last restart)" : $"{CataloggerMetrics.MessagesReceived.Value:N0} since last restart", true ); embed.AddField("Shard", $"{shardId + 1} of {client.Shards.Count}", true); embed.AddField( "Uptime", $"{(CataloggerMetrics.Startup - clock.GetCurrentInstant()).Prettify(TimeUnit.Second)}\n" + $"since ", true ); embed.AddField( "Numbers", $"{CataloggerMetrics.MessagesStored.Value:N0} messages " + $"from {guildCache.Size:N0} servers\n" + $"Cached {channelCache.Size:N0} channels, {roleCache.Size:N0} roles, {emojiCache.Size:N0} emojis", false ); IEmbed[] embeds = [embed.Build().GetOrThrow()]; return (Result) await channelApi.EditMessageAsync(msg.ChannelID, msg.ID, content: "", embeds: embeds); } // TODO: add more checks around response format, configurable prometheus endpoint private async Task MessagesRate() { if (!config.Logging.EnableMetrics) return null; try { var query = HttpUtility.UrlEncode("delta(catalogger_received_messages[5m])"); var resp = await _client.GetAsync($"http://localhost:9090/api/v1/query?query={query}"); resp.EnsureSuccessStatusCode(); var data = await resp.Content.ReadFromJsonAsync(); var rawNumber = (data?.data.result[0].value[1] as JsonElement?)?.GetString(); return double.TryParse(rawNumber, out var rate) ? rate : null; } catch (Exception e) { _logger.Warning(e, "Failed querying Prometheus for message rate"); return null; } } // ReSharper disable InconsistentNaming, ClassNeverInstantiated.Local private record PrometheusResponse(PrometheusData data); private record PrometheusData(PrometheusResult[] result); private record PrometheusResult(object[] value); // ReSharper restore InconsistentNaming, ClassNeverInstantiated.Local }