├── .gitignore ├── Data ├── Migrations │ ├── .editorconfig │ ├── 20230204063321_RemoveModRole.cs │ ├── 20230117053251_RemoveBlocking.cs │ ├── 20220319195852_InitialEFSetup.cs │ ├── BotDatabaseContextModelSnapshot.cs │ ├── 20230204063321_RemoveModRole.Designer.cs │ ├── 20230117053251_RemoveBlocking.Designer.cs │ ├── 20220319195852_InitialEFSetup.Designer.cs │ ├── 20221123062847_LongToUlong.Designer.cs │ └── 20221123062847_LongToUlong.cs ├── UserEntry.cs ├── GuildConfig.cs ├── Extensions.cs └── BotDatabaseContext.cs ├── .config └── dotnet-tools.json ├── BackgroundServices ├── BackgroundService.cs ├── ExternalStatisticsReporting.cs ├── ShardBackgroundWorker.cs ├── DataRetention.cs ├── AutoUserDownload.cs └── BirthdayRoleUpdate.cs ├── .vscode ├── launch.json └── tasks.json ├── Readme.md ├── ApplicationCommands ├── ModalResponder.cs ├── HelpModule.cs ├── BirthdayOverrideModule.cs ├── ExportModule.cs ├── TzAutocompleteHandler.cs ├── BotModuleBase.cs ├── BirthdayModule.cs └── ConfigModule.cs ├── BirthdayBot.csproj ├── Common.cs ├── Program.cs ├── Configuration.cs ├── ShardManager.cs ├── ShardInstance.cs ├── .editorconfig └── COPYING /.gitignore: -------------------------------------------------------------------------------- 1 | [Bb]in/ 2 | [Oo]bj/ 3 | .vs/ 4 | *.user 5 | *.sln -------------------------------------------------------------------------------- /Data/Migrations/.editorconfig: -------------------------------------------------------------------------------- 1 | [*.cs] 2 | generated_code = true 3 | dotnet_analyzer_diagnostic.category-CodeQuality.severity = none 4 | dotnet_diagnostic.CS1591.severity = none -------------------------------------------------------------------------------- /.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": { 5 | "dotnet-ef": { 6 | "version": "9.0.10", 7 | "commands": [ 8 | "dotnet-ef" 9 | ] 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /BackgroundServices/BackgroundService.cs: -------------------------------------------------------------------------------- 1 | namespace BirthdayBot.BackgroundServices; 2 | abstract class BackgroundService { 3 | /// 4 | /// Use to avoid excessive concurrent work on the database. 5 | /// 6 | protected static SemaphoreSlim DbAccessGate { get; private set; } = null!; 7 | 8 | protected ShardInstance Shard { get; } 9 | 10 | public BackgroundService(ShardInstance instance) { 11 | Shard = instance; 12 | DbAccessGate ??= new SemaphoreSlim(instance.Config.MaxConcurrentOperations); 13 | } 14 | 15 | protected void Log(string message) => Shard.Log(GetType().Name, message); 16 | 17 | public abstract Task OnTick(int tickCount, CancellationToken token); 18 | } 19 | -------------------------------------------------------------------------------- /Data/UserEntry.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.ComponentModel.DataAnnotations.Schema; 3 | 4 | namespace BirthdayBot.Data; 5 | [Table("user_birthdays")] 6 | public class UserEntry { 7 | [Key] 8 | public ulong GuildId { get; set; } 9 | [Key] 10 | public ulong UserId { get; set; } 11 | 12 | public int BirthMonth { get; set; } 13 | 14 | public int BirthDay { get; set; } 15 | 16 | public string? TimeZone { get; set; } 17 | 18 | public DateTimeOffset LastSeen { get; set; } 19 | 20 | [ForeignKey(nameof(GuildConfig.GuildId))] 21 | [InverseProperty(nameof(GuildConfig.UserEntries))] 22 | public GuildConfig Guild { get; set; } = null!; 23 | 24 | /// 25 | /// Gets if this instance is new and does not (yet) exist in the database. 26 | /// 27 | [NotMapped] 28 | public bool IsNew { get; set; } 29 | } 30 | -------------------------------------------------------------------------------- /Data/GuildConfig.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.ComponentModel.DataAnnotations.Schema; 3 | 4 | namespace BirthdayBot.Data; 5 | [Table("settings")] 6 | public class GuildConfig { 7 | [Key] 8 | public ulong GuildId { get; set; } 9 | 10 | [Column("role_id")] 11 | public ulong? BirthdayRole { get; set; } 12 | 13 | [Column("channel_announce_id")] 14 | public ulong? AnnouncementChannel { get; set; } 15 | 16 | [Column("time_zone")] 17 | public string? GuildTimeZone { get; set; } 18 | 19 | public string? AnnounceMessage { get; set; } 20 | 21 | public string? AnnounceMessagePl { get; set; } 22 | 23 | public bool AnnouncePing { get; set; } 24 | 25 | public DateTimeOffset LastSeen { get; set; } 26 | 27 | [InverseProperty(nameof(UserEntry.Guild))] 28 | public ICollection UserEntries { get; set; } = null!; 29 | 30 | /// 31 | /// Gets if this instance is new and does not (yet) exist in the database. 32 | /// 33 | [NotMapped] 34 | public bool IsNew { get; set; } 35 | } 36 | -------------------------------------------------------------------------------- /Data/Extensions.cs: -------------------------------------------------------------------------------- 1 | namespace BirthdayBot.Data; 2 | internal static class Extensions { 3 | /// 4 | /// Gets the corresponding for this guild, or a new one if one does not exist. 5 | /// If it doesn't exist in the database, returns true. 6 | /// 7 | public static GuildConfig GetConfigOrNew(this SocketGuild guild, BotDatabaseContext db) 8 | => db.GuildConfigurations.Where(g => g.GuildId == guild.Id).FirstOrDefault() 9 | ?? new GuildConfig() { IsNew = true, GuildId = guild.Id }; 10 | 11 | /// 12 | /// Gets the corresponding for this user in this guild, or a new one if one does not exist. 13 | /// If it doesn't exist in the database, returns true. 14 | /// 15 | public static UserEntry GetUserEntryOrNew(this SocketGuildUser user, BotDatabaseContext db) 16 | => db.UserEntries.Where(u => u.GuildId == user.Guild.Id && u.UserId == user.Id).FirstOrDefault() 17 | ?? new UserEntry() { IsNew = true, GuildId = user.Guild.Id, UserId = user.Id }; 18 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | // Use IntelliSense to find out which attributes exist for C# debugging 6 | // Use hover for the description of the existing attributes 7 | // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md 8 | "name": ".NET Core Launch (console)", 9 | "type": "coreclr", 10 | "request": "launch", 11 | "preLaunchTask": "build", 12 | // If you have changed target frameworks, make sure to update the program path. 13 | "program": "${workspaceFolder}/bin/Debug/net8.0/BirthdayBot.dll", 14 | "args": [ "-c", "${workspaceFolder}/bin/Debug/settings.json" ], 15 | "cwd": "${workspaceFolder}", 16 | // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console 17 | "console": "internalConsole", 18 | "stopAtEntry": false 19 | }, 20 | { 21 | "name": ".NET Core Attach", 22 | "type": "coreclr", 23 | "request": "attach" 24 | } 25 | ] 26 | } -------------------------------------------------------------------------------- /Data/Migrations/20230204063321_RemoveModRole.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace BirthdayBot.Data.Migrations 6 | { 7 | /// 8 | public partial class RemoveModRole : Migration 9 | { 10 | /// 11 | protected override void Up(MigrationBuilder migrationBuilder) 12 | { 13 | migrationBuilder.DropColumn( 14 | name: "moderated", 15 | table: "settings"); 16 | 17 | migrationBuilder.DropColumn( 18 | name: "moderator_role", 19 | table: "settings"); 20 | } 21 | 22 | /// 23 | protected override void Down(MigrationBuilder migrationBuilder) 24 | { 25 | migrationBuilder.AddColumn( 26 | name: "moderated", 27 | table: "settings", 28 | type: "boolean", 29 | nullable: false, 30 | defaultValue: false); 31 | 32 | migrationBuilder.AddColumn( 33 | name: "moderator_role", 34 | table: "settings", 35 | type: "numeric(20,0)", 36 | nullable: true); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Birthday Bot 2 | An automated way to recognize birthdays in your community! 3 | 4 | [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/J3J65TW2E) 5 | 6 | #### Documentation, help, resources 7 | * [Main website, user documentation](https://noithecat.dev/bots/BirthdayBot) 8 | * [Official server](https://discord.gg/JCRyFk7) 9 | 10 | #### Running your own instance 11 | You need: 12 | * .NET 8 (https://dotnet.microsoft.com/en-us/) 13 | * PostgreSQL (https://www.postgresql.org/) 14 | * A Discord bot token (https://discord.com/developers/applications) 15 | 16 | Get your bot token and set up your database user and schema, then create a JSON file containing the following: 17 | ```jsonc 18 | { 19 | "BotToken": "your bot token here", 20 | "SqlHost": "localhost", // optional 21 | "SqlDatabase": "birthdaybot", // optional 22 | "SqlUser": "birthdaybot", // required 23 | "SqlPassword": "birthdaybot" // required; no other authentication methods are currently supported 24 | } 25 | ``` 26 | 27 | Then run the following commands: 28 | ```sh 29 | $ dotnet restore 30 | $ dotnet tool restore 31 | $ dotnet ef database update -- -c path/to/config.json 32 | ``` 33 | 34 | And finally, to run the bot: 35 | ```sh 36 | $ dotnet run -c Release -- -c path/to/config.json 37 | ``` -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet", 7 | "type": "process", 8 | "args": [ 9 | "build", 10 | "${workspaceFolder}/BirthdayBot.csproj", 11 | "/property:GenerateFullPaths=true", 12 | "/consoleloggerparameters:NoSummary" 13 | ], 14 | "problemMatcher": "$msCompile" 15 | }, 16 | { 17 | "label": "publish", 18 | "command": "dotnet", 19 | "type": "process", 20 | "args": [ 21 | "publish", 22 | "${workspaceFolder}/BirthdayBot.csproj", 23 | "/property:GenerateFullPaths=true", 24 | "/consoleloggerparameters:NoSummary" 25 | ], 26 | "problemMatcher": "$msCompile" 27 | }, 28 | { 29 | "label": "watch", 30 | "command": "dotnet", 31 | "type": "process", 32 | "args": [ 33 | "watch", 34 | "run", 35 | "--project", 36 | "${workspaceFolder}/BirthdayBot.csproj" 37 | ], 38 | "problemMatcher": "$msCompile" 39 | } 40 | ] 41 | } -------------------------------------------------------------------------------- /Data/Migrations/20230117053251_RemoveBlocking.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace BirthdayBot.Data.Migrations 6 | { 7 | /// 8 | public partial class RemoveBlocking : Migration 9 | { 10 | /// 11 | protected override void Up(MigrationBuilder migrationBuilder) 12 | { 13 | migrationBuilder.DropTable( 14 | name: "banned_users"); 15 | } 16 | 17 | /// 18 | protected override void Down(MigrationBuilder migrationBuilder) 19 | { 20 | migrationBuilder.CreateTable( 21 | name: "banned_users", 22 | columns: table => new 23 | { 24 | guildid = table.Column(name: "guild_id", type: "numeric(20,0)", nullable: false), 25 | userid = table.Column(name: "user_id", type: "numeric(20,0)", nullable: false) 26 | }, 27 | constraints: table => 28 | { 29 | table.PrimaryKey("banned_users_pkey", x => new { x.guildid, x.userid }); 30 | table.ForeignKey( 31 | name: "banned_users_guild_id_fkey", 32 | column: x => x.guildid, 33 | principalTable: "settings", 34 | principalColumn: "guild_id", 35 | onDelete: ReferentialAction.Cascade); 36 | }); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /ApplicationCommands/ModalResponder.cs: -------------------------------------------------------------------------------- 1 | namespace BirthdayBot.ApplicationCommands; 2 | /// 3 | /// An instance-less class meant to handle incoming submitted modals. 4 | /// 5 | static class ModalResponder { 6 | private delegate Task Responder(SocketModal modal, SocketGuildChannel channel, 7 | Dictionary data); 8 | 9 | internal static async Task DiscordClient_ModalSubmitted(ShardInstance inst, SocketModal arg) { 10 | Responder handler = arg.Data.CustomId switch { 11 | ConfigModule.SubCmdsConfigAnnounce.ModalCidAnnounce => ConfigModule.SubCmdsConfigAnnounce.CmdSetMessageResponse, 12 | _ => DefaultHandler 13 | }; 14 | 15 | var data = arg.Data.Components.ToDictionary(k => k.CustomId); 16 | 17 | if (arg.Channel is not SocketGuildChannel channel) { 18 | inst.Log(nameof(ModalResponder), $"Modal of type `{arg.Data.CustomId}` but channel data unavailable. " + 19 | $"Sender ID {arg.User.Id}, name {arg.User}."); 20 | await arg.RespondAsync(":x: Invalid request. Are you trying this command from a channel the bot can't see?") 21 | .ConfigureAwait(false); 22 | return; 23 | } 24 | 25 | try { 26 | inst.Log(nameof(ModalResponder), $"Modal of type `{arg.Data.CustomId}` at {channel.Guild}!{arg.User}."); 27 | await handler(arg, channel, data).ConfigureAwait(false); 28 | } catch (Exception e) { 29 | inst.Log(nameof(ModalResponder), $"Unhandled exception. {e}"); 30 | await arg.RespondAsync(ShardInstance.InternalError); 31 | } 32 | } 33 | 34 | private static async Task DefaultHandler(SocketModal modal, SocketGuildChannel channel, 35 | Dictionary data) 36 | => await modal.RespondAsync(":x: ...???"); 37 | } 38 | -------------------------------------------------------------------------------- /BirthdayBot.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 3.6.1 5 | NoiTheCat 6 | 7 | Exe 8 | net8.0 9 | enable 10 | enable 11 | en 12 | 13 | 14 | 15 | none 16 | false 17 | 0 18 | AnyCPU 19 | 20 | 21 | 22 | AnyCPU 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | all 31 | runtime; build; native; contentfiles; analyzers; buildtransitive 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | Docs/**;$(DefaultItemExcludes) 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /Data/BotDatabaseContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Npgsql; 3 | 4 | namespace BirthdayBot.Data; 5 | public class BotDatabaseContext : DbContext { 6 | private static readonly string _connectionString; 7 | 8 | static BotDatabaseContext() { 9 | // Get our own config loaded just for the SQL stuff 10 | var conf = new Configuration(); 11 | _connectionString = new NpgsqlConnectionStringBuilder() { 12 | Host = conf.SqlHost ?? "localhost", // default to localhost 13 | Database = conf.SqlDatabase, 14 | Username = conf.SqlUsername, 15 | Password = conf.SqlPassword, 16 | ApplicationName = conf.SqlApplicationName 17 | }.ToString(); 18 | } 19 | 20 | public DbSet GuildConfigurations { get; set; } = null!; 21 | public DbSet UserEntries { get; set; } = null!; 22 | 23 | protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) 24 | => optionsBuilder 25 | .UseNpgsql(_connectionString) 26 | .UseSnakeCaseNamingConvention(); 27 | 28 | protected override void OnModelCreating(ModelBuilder modelBuilder) { 29 | modelBuilder.Entity(entity => { 30 | entity.HasKey(e => e.GuildId) 31 | .HasName("settings_pkey"); 32 | 33 | entity.Property(e => e.GuildId).ValueGeneratedNever(); 34 | 35 | entity.Property(e => e.LastSeen).HasDefaultValueSql("now()"); 36 | }); 37 | 38 | modelBuilder.Entity(entity => { 39 | entity.HasKey(e => new { e.GuildId, e.UserId }) 40 | .HasName("user_birthdays_pkey"); 41 | 42 | entity.Property(e => e.LastSeen).HasDefaultValueSql("now()"); 43 | 44 | entity.HasOne(d => d.Guild) 45 | .WithMany(p => p.UserEntries) 46 | .HasForeignKey(d => d.GuildId) 47 | .HasConstraintName("user_birthdays_guild_id_fkey") 48 | .OnDelete(DeleteBehavior.Cascade); 49 | }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /BackgroundServices/ExternalStatisticsReporting.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | 3 | namespace BirthdayBot.BackgroundServices; 4 | /// 5 | /// Reports user count statistics to external services on a shard by shard basis. 6 | /// 7 | class ExternalStatisticsReporting : BackgroundService { 8 | readonly int ProcessInterval; 9 | readonly int ProcessOffset; 10 | 11 | private static readonly HttpClient _httpClient = new(); 12 | 13 | public ExternalStatisticsReporting(ShardInstance instance) : base(instance) { 14 | ProcessInterval = 1200 / Shard.Config.BackgroundInterval; // Process every ~20 minutes 15 | ProcessOffset = 300 / Shard.Config.BackgroundInterval; // No processing until ~5 minutes after shard start 16 | } 17 | 18 | public override async Task OnTick(int tickCount, CancellationToken token) { 19 | if (tickCount < ProcessOffset) return; 20 | if (tickCount % ProcessInterval != 0) return; 21 | 22 | var botId = Shard.DiscordClient.CurrentUser.Id; 23 | if (botId == 0) return; 24 | var count = Shard.DiscordClient.Guilds.Count; 25 | 26 | var dbotsToken = Shard.Config.DBotsToken; 27 | if (dbotsToken != null) await SendDiscordBots(dbotsToken, count, botId, token); 28 | } 29 | 30 | private async Task SendDiscordBots(string apiToken, int userCount, ulong botId, CancellationToken token) { 31 | try { 32 | const string dBotsApiUrl = "https://discord.bots.gg/api/v1/bots/{0}/stats"; 33 | const string Body = "{{ \"guildCount\": {0}, \"shardCount\": {1}, \"shardId\": {2} }}"; 34 | var uri = new Uri(string.Format(dBotsApiUrl, botId)); 35 | 36 | var post = new HttpRequestMessage(HttpMethod.Post, uri); 37 | post.Headers.Add("Authorization", apiToken); 38 | post.Content = new StringContent(string.Format(Body, 39 | userCount, Shard.Config.ShardTotal, Shard.ShardId), 40 | Encoding.UTF8, "application/json"); 41 | 42 | await _httpClient.SendAsync(post, token); 43 | Log("Discord Bots: Update successful."); 44 | } catch (Exception ex) { 45 | Log("Discord Bots: Exception encountered during update: " + ex.Message); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Common.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | 3 | namespace BirthdayBot; 4 | static class Common { 5 | /// 6 | /// Formats a user's name to a consistent, readable format which makes use of their nickname. 7 | /// 8 | public static string FormatName(SocketGuildUser member, bool ping) { 9 | if (ping) return member.Mention; 10 | 11 | static string escapeFormattingCharacters(string input) { 12 | var result = new StringBuilder(); 13 | foreach (var c in input) { 14 | if (c is '\\' or '_' or '~' or '*' or '@' or '`') { 15 | result.Append('\\'); 16 | } 17 | result.Append(c); 18 | } 19 | return result.ToString(); 20 | } 21 | 22 | if (member.DiscriminatorValue == 0) { 23 | var username = escapeFormattingCharacters(member.GlobalName ?? member.Username); 24 | if (member.Nickname != null) { 25 | return $"{escapeFormattingCharacters(member.Nickname)} ({username})"; 26 | } 27 | return username; 28 | } else { 29 | var username = escapeFormattingCharacters(member.Username); 30 | if (member.Nickname != null) { 31 | return $"{escapeFormattingCharacters(member.Nickname)} ({username}#{member.Discriminator})"; 32 | } 33 | return $"{username}#{member.Discriminator}"; 34 | } 35 | } 36 | 37 | public static Dictionary MonthNames { get; } = new() { 38 | { 1, "Jan" }, { 2, "Feb" }, { 3, "Mar" }, { 4, "Apr" }, { 5, "May" }, { 6, "Jun" }, 39 | { 7, "Jul" }, { 8, "Aug" }, { 9, "Sep" }, { 10, "Oct" }, { 11, "Nov" }, { 12, "Dec" } 40 | }; 41 | 42 | /// 43 | /// An alternative to . 44 | /// Returns true if *most* members have been downloaded. 45 | /// Used as a workaround check due to Discord.Net occasionally unable to actually download all members. 46 | /// 47 | public static bool HasMostMembersDownloaded(SocketGuild guild) { 48 | if (guild.HasAllMembers) return true; 49 | if (guild.MemberCount > 30) { 50 | // For guilds of size over 30, require 85% or more of the members to be known 51 | // (26/30, 42/50, 255/300, etc) 52 | var threshold = (int)(guild.MemberCount * 0.85); 53 | return guild.DownloadedMemberCount >= threshold; 54 | } else { 55 | // For smaller guilds, fail if two or more members are missing 56 | return guild.MemberCount - guild.DownloadedMemberCount <= 2; 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Program.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.InteropServices; 2 | 3 | namespace BirthdayBot; 4 | class Program { 5 | private static ShardManager _bot = null!; 6 | private static readonly DateTimeOffset _botStartTime = DateTimeOffset.UtcNow; 7 | 8 | /// 9 | /// Returns the amount of time the program has been running in a human-readable format. 10 | /// 11 | public static string BotUptime => (DateTimeOffset.UtcNow - _botStartTime).ToString("d' days, 'hh':'mm':'ss"); 12 | 13 | static async Task Main() { 14 | Configuration? cfg = null; 15 | try { 16 | cfg = new Configuration(); 17 | } catch (Exception ex) { 18 | Console.WriteLine(ex); 19 | Environment.Exit(2); 20 | } 21 | 22 | _bot = new ShardManager(cfg); 23 | 24 | Console.CancelKeyPress += static (s, e) => { 25 | e.Cancel = true; 26 | Log("Shutdown", "Caught Ctrl-C or SIGINT."); 27 | DoShutdown(); 28 | }; 29 | if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) { 30 | _sigtermHandler = PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx => { 31 | ctx.Cancel = true; 32 | Log("Shutdown", "Caught SIGTERM."); 33 | DoShutdown(); 34 | }); 35 | } 36 | 37 | await _shutdownBlock.Task; 38 | Log(nameof(BirthdayBot), $"Shutdown complete. Uptime: {BotUptime}"); 39 | } 40 | 41 | /// 42 | /// Sends a formatted message to console. 43 | /// 44 | public static void Log(string source, string message) { 45 | var ts = DateTime.Now; 46 | var ls = new string[] { "\r\n", "\n" }; 47 | foreach (var item in message.Split(ls, StringSplitOptions.None)) 48 | Console.WriteLine($"{ts:s} [{source}] {item}"); 49 | } 50 | 51 | #region Shutdown logic 52 | private static int _isShuttingDown = 0; 53 | private static PosixSignalRegistration? _sigtermHandler; // DO NOT REMOVE else signal handler is GCed away 54 | private static readonly TaskCompletionSource _shutdownBlock = new(TaskCreationOptions.RunContinuationsAsynchronously); 55 | private static void DoShutdown() { 56 | if (Interlocked.Exchange(ref _isShuttingDown, 1) == 1) return; 57 | 58 | Log("Shutdown", "Shutting down..."); 59 | var dispose = Task.Run(_bot.Dispose); 60 | if (!dispose.Wait(10000)) { 61 | Log("Shutdown", "Normal shutdown is taking too long. We're force-quitting."); 62 | Environment.Exit(1); 63 | } 64 | 65 | Environment.ExitCode = 0; 66 | _shutdownBlock.SetResult(true); 67 | } 68 | #endregion 69 | } 70 | -------------------------------------------------------------------------------- /ApplicationCommands/HelpModule.cs: -------------------------------------------------------------------------------- 1 | using Discord.Interactions; 2 | 3 | namespace BirthdayBot.ApplicationCommands; 4 | [CommandContextType(InteractionContextType.Guild, InteractionContextType.BotDm)] 5 | public class HelpModule : BotModuleBase { 6 | private const string TopMessage = 7 | "Thank you for using Birthday Bot!\n" + 8 | "Support, data policy, more info: https://noithecat.dev/bots/BirthdayBot\n\n" + 9 | "This bot is provided for free, without any paywalls or exclusive paid features. If this bot has been useful to you, " + 10 | "please consider making a small contribution via the author's Ko-fi: https://ko-fi.com/noithecat."; 11 | private const string RegularCommandsField = 12 | $"`/birthday` - {BirthdayModule.HelpCmdBirthday}\n" + 13 | $"` ⤷get` - {BirthdayModule.HelpCmdGet}\n" + 14 | $"` ⤷show-nearest` - {BirthdayModule.HelpCmdNearest}\n" + 15 | $"` ⤷set date` - {BirthdayModule.HelpCmdSetDate}\n" + 16 | $"` ⤷set timezone` - {BirthdayModule.HelpCmdSetZone}\n" + 17 | $"` ⤷remove` - {BirthdayModule.HelpCmdRemove}"; 18 | private const string ModCommandsField = 19 | $"`/config` - {ConfigModule.HelpCmdConfig}\n" + 20 | $"` ⤷check` - {ConfigModule.HelpCmdCheck}\n" + 21 | $"` ⤷announce` - {ConfigModule.HelpCmdAnnounce}\n" + 22 | $"` ⤷` See also: `/config announce help`.\n" + 23 | $"` ⤷birthday-role` - {ConfigModule.HelpCmdBirthdayRole}\n" + 24 | $"`/export-birthdays` - {ExportModule.HelpCmdExport}\n" + 25 | $"`/override` - {BirthdayOverrideModule.HelpCmdOverride}\n" + 26 | $"` ⤷set-birthday`, `⤷set-timezone`, `⤷remove`\n" + 27 | "**Caution:** Skipping optional parameters __removes__ their configuration."; 28 | 29 | [SlashCommand("help", "Show an overview of available commands.")] 30 | public async Task CmdHelp() { 31 | const string DMWarn = "Please note that this bot works in servers only. " + 32 | "The bot will not respond to any other commands within a DM."; 33 | #if DEBUG 34 | var ver = "DEBUG flag set"; 35 | #else 36 | var ver = "v" + System.Reflection.Assembly.GetExecutingAssembly().GetName().Version!.ToString(3); 37 | #endif 38 | var result = new EmbedBuilder() 39 | .WithAuthor("Help & About") 40 | .WithFooter($"Birthday Bot {ver} - Shard {Shard.ShardId:00} up {Program.BotUptime}", 41 | Context.Client.CurrentUser.GetAvatarUrl()) 42 | .WithDescription(TopMessage) 43 | .AddField("Commands", RegularCommandsField) 44 | .AddField("Moderator commands", ModCommandsField) 45 | .Build(); 46 | await RespondAsync(text: Context.Channel is IDMChannel ? DMWarn : null, embed: result).ConfigureAwait(false); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /BackgroundServices/ShardBackgroundWorker.cs: -------------------------------------------------------------------------------- 1 | namespace BirthdayBot.BackgroundServices; 2 | /// 3 | /// Handles the execution of periodic background tasks specific to each shard. 4 | /// 5 | class ShardBackgroundWorker : IDisposable { 6 | /// 7 | /// The interval, in seconds, in which background tasks are attempted to be run within a shard. 8 | /// 9 | private int Interval { get; } 10 | 11 | private readonly Task _workerTask; 12 | private readonly CancellationTokenSource _workerCanceller; 13 | private readonly List _workers; 14 | private int _tickCount = -1; 15 | 16 | private ShardInstance Instance { get; } 17 | 18 | public DateTimeOffset LastBackgroundRun { get; private set; } 19 | public string? CurrentExecutingService { get; private set; } 20 | 21 | public ShardBackgroundWorker(ShardInstance instance) { 22 | Instance = instance; 23 | Interval = instance.Config.BackgroundInterval; 24 | _workerCanceller = new CancellationTokenSource(); 25 | 26 | _workers = new List() 27 | { 28 | {new AutoUserDownload(instance)}, 29 | {new BirthdayRoleUpdate(instance)}, 30 | {new DataRetention(instance)}, 31 | {new ExternalStatisticsReporting(instance)} 32 | }; 33 | 34 | _workerTask = Task.Factory.StartNew(WorkerLoop, _workerCanceller.Token); 35 | } 36 | 37 | public void Dispose() { 38 | _workerCanceller.Cancel(); 39 | _workerTask.Wait(5000); 40 | if (!_workerTask.IsCompleted) 41 | Instance.Log("Dispose", "Warning: Background worker has not yet stopped. Forcing its disposal."); 42 | _workerTask.Dispose(); 43 | _workerCanceller.Dispose(); 44 | } 45 | 46 | /// 47 | /// *The* background task for the shard. 48 | /// Executes service tasks and handles errors. 49 | /// 50 | private async Task WorkerLoop() { 51 | LastBackgroundRun = DateTimeOffset.UtcNow; 52 | try { 53 | while (!_workerCanceller.IsCancellationRequested) { 54 | await Task.Delay(Interval * 1000, _workerCanceller.Token).ConfigureAwait(false); 55 | 56 | // Skip this round of task execution if the client is not connected 57 | if (Instance.DiscordClient.ConnectionState != ConnectionState.Connected) continue; 58 | 59 | // Within a shard, execute tasks sequentially (background tasks are parallel only by shard) 60 | _tickCount++; 61 | foreach (var service in _workers) { 62 | CurrentExecutingService = service.GetType().Name; 63 | try { 64 | if (_workerCanceller.IsCancellationRequested) break; 65 | await service.OnTick(_tickCount, _workerCanceller.Token).ConfigureAwait(false); 66 | } catch (Exception ex) when (ex is not 67 | (TaskCanceledException or OperationCanceledException or ObjectDisposedException)) { 68 | Instance.Log(CurrentExecutingService, ex.ToString()); 69 | } 70 | } 71 | CurrentExecutingService = null; 72 | LastBackgroundRun = DateTimeOffset.UtcNow; 73 | } 74 | } catch (TaskCanceledException) { } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /BackgroundServices/DataRetention.cs: -------------------------------------------------------------------------------- 1 | using BirthdayBot.Data; 2 | using Microsoft.EntityFrameworkCore; 3 | using System.Text; 4 | 5 | namespace BirthdayBot.BackgroundServices; 6 | /// 7 | /// Automatically removes database information for guilds that have not been accessed in a long time. 8 | /// 9 | class DataRetention : BackgroundService { 10 | private readonly int ProcessInterval; 11 | 12 | // Amount of days without updates before data is considered stale and up for deletion. 13 | const int StaleGuildThreshold = 180; 14 | const int StaleUserThreashold = 360; 15 | 16 | public DataRetention(ShardInstance instance) : base(instance) 17 | => ProcessInterval = 21600 / Shard.Config.BackgroundInterval; // Process about once per six hours 18 | 19 | public override async Task OnTick(int tickCount, CancellationToken token) { 20 | // Run only a subset of shards each time, each running every ProcessInterval ticks. 21 | if ((tickCount + Shard.ShardId) % ProcessInterval != 0) return; 22 | 23 | try { 24 | await DbAccessGate.WaitAsync(token); 25 | await RemoveStaleEntriesAsync(); 26 | } finally { 27 | try { 28 | DbAccessGate.Release(); 29 | } catch (ObjectDisposedException) { } 30 | } 31 | } 32 | 33 | private async Task RemoveStaleEntriesAsync() { 34 | using var db = new BotDatabaseContext(); 35 | var now = DateTimeOffset.UtcNow; 36 | 37 | // Update guilds 38 | var localGuilds = Shard.DiscordClient.Guilds.Select(g => g.Id).ToList(); 39 | var updatedGuilds = await db.GuildConfigurations 40 | .Where(g => localGuilds.Contains(g.GuildId)) 41 | .ExecuteUpdateAsync(upd => upd.SetProperty(p => p.LastSeen, now)); 42 | 43 | // Update guild users 44 | var updatedUsers = 0; 45 | foreach (var guild in Shard.DiscordClient.Guilds) { 46 | var localUsers = guild.Users.Select(u => u.Id).ToList(); 47 | updatedUsers += await db.UserEntries 48 | .Where(gu => gu.GuildId == guild.Id) 49 | .Where(gu => localUsers.Contains(gu.UserId)) 50 | .ExecuteUpdateAsync(upd => upd.SetProperty(p => p.LastSeen, now)); 51 | } 52 | 53 | // And let go of old data 54 | var staleGuildCount = await db.GuildConfigurations 55 | .Where(g => localGuilds.Contains(g.GuildId)) 56 | .Where(g => now - TimeSpan.FromDays(StaleGuildThreshold) > g.LastSeen) 57 | .ExecuteDeleteAsync(); 58 | var staleUserCount = await db.UserEntries 59 | .Where(gu => localGuilds.Contains(gu.GuildId)) 60 | .Where(gu => now - TimeSpan.FromDays(StaleUserThreashold) > gu.LastSeen) 61 | .ExecuteDeleteAsync(); 62 | 63 | // Build report 64 | var resultText = new StringBuilder(); 65 | resultText.Append($"Updated {updatedGuilds} guilds, {updatedUsers} users."); 66 | if (staleGuildCount != 0 || staleUserCount != 0) { 67 | resultText.Append(" Discarded "); 68 | if (staleGuildCount != 0) { 69 | resultText.Append($"{staleGuildCount} guilds"); 70 | if (staleUserCount != 0) resultText.Append(", "); 71 | } 72 | if (staleUserCount != 0) { 73 | resultText.Append($"{staleUserCount} users"); 74 | } 75 | resultText.Append('.'); 76 | } 77 | Log(resultText.ToString()); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /ApplicationCommands/BirthdayOverrideModule.cs: -------------------------------------------------------------------------------- 1 | using BirthdayBot.Data; 2 | using Discord.Interactions; 3 | using static BirthdayBot.Common; 4 | 5 | namespace BirthdayBot.ApplicationCommands; 6 | [Group("override", HelpCmdOverride)] 7 | [DefaultMemberPermissions(GuildPermission.ManageGuild)] 8 | [CommandContextType(InteractionContextType.Guild)] 9 | public class BirthdayOverrideModule : BotModuleBase { 10 | public const string HelpCmdOverride = "Commands to set options for other users."; 11 | const string HelpOptOvTarget = "The user whose data to modify."; 12 | 13 | [SlashCommand("set-birthday", "Set a user's birthday on their behalf.")] 14 | public async Task OvSetBirthday([Summary(description: HelpOptOvTarget)] SocketGuildUser target, 15 | [Summary(description: HelpOptDate)] string date) { 16 | // IMPORTANT: If editing here, reflect changes as needed in BirthdayModule. 17 | int inmonth, inday; 18 | try { 19 | (inmonth, inday) = ParseDate(date); 20 | } catch (FormatException e) { 21 | // Our parse method's FormatException has its message to send out to Discord. 22 | await RespondAsync(e.Message, ephemeral: true).ConfigureAwait(false); 23 | return; 24 | } 25 | 26 | using var db = new BotDatabaseContext(); 27 | var guild = ((SocketTextChannel)Context.Channel).Guild.GetConfigOrNew(db); 28 | if (guild.IsNew) db.GuildConfigurations.Add(guild); // Satisfy foreign key constraint 29 | var user = target.GetUserEntryOrNew(db); 30 | if (user.IsNew) db.UserEntries.Add(user); 31 | user.BirthMonth = inmonth; 32 | user.BirthDay = inday; 33 | await db.SaveChangesAsync(); 34 | 35 | await RespondAsync($":white_check_mark: {FormatName(target, false)}'s birthday has been set to " + 36 | $"**{FormatDate(inmonth, inday)}**.").ConfigureAwait(false); 37 | } 38 | 39 | [SlashCommand("set-timezone", "Set a user's time zone on their behalf.")] 40 | public async Task OvSetTimezone([Summary(description: HelpOptOvTarget)] SocketGuildUser target, 41 | [Summary(description: HelpOptZone), Autocomplete] string zone) { 42 | using var db = new BotDatabaseContext(); 43 | 44 | var user = target.GetUserEntryOrNew(db); 45 | if (user.IsNew) { 46 | await RespondAsync($":x: {FormatName(target, false)} does not have a birthday set.") 47 | .ConfigureAwait(false); 48 | return; 49 | } 50 | 51 | string newzone; 52 | try { 53 | newzone = ParseTimeZone(zone); 54 | } catch (FormatException e) { 55 | await RespondAsync(e.Message, ephemeral: true).ConfigureAwait(false); 56 | return; 57 | } 58 | user.TimeZone = newzone; 59 | await db.SaveChangesAsync(); 60 | await RespondAsync($":white_check_mark: {FormatName(target, false)}'s time zone has been set to " + 61 | $"**{newzone}**.").ConfigureAwait(false); 62 | } 63 | 64 | [SlashCommand("remove-birthday", "Remove a user's birthday information on their behalf.")] 65 | public async Task OvRemove([Summary(description: HelpOptOvTarget)] SocketGuildUser target) { 66 | using var db = new BotDatabaseContext(); 67 | var user = target.GetUserEntryOrNew(db); 68 | if (!user.IsNew) { 69 | db.UserEntries.Remove(user); 70 | await db.SaveChangesAsync(); 71 | await RespondAsync($":white_check_mark: {FormatName(target, false)}'s birthday in this server has been removed.") 72 | .ConfigureAwait(false); 73 | } else { 74 | await RespondAsync($":white_check_mark: {FormatName(target, false)}'s birthday is not registered.") 75 | .ConfigureAwait(false); 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Data/Migrations/20220319195852_InitialEFSetup.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | // command used: 5 | // dotnet ef migrations add InitialEFSetup --output-dir Data/Migrations 6 | // (don't forget to replace with a proper migration name) 7 | 8 | #nullable disable 9 | 10 | namespace BirthdayBot.Data.Migrations 11 | { 12 | public partial class InitialEFSetup : Migration 13 | { 14 | protected override void Up(MigrationBuilder migrationBuilder) 15 | { 16 | migrationBuilder.CreateTable( 17 | name: "settings", 18 | columns: table => new 19 | { 20 | guild_id = table.Column(type: "bigint", nullable: false), 21 | role_id = table.Column(type: "bigint", nullable: true), 22 | channel_announce_id = table.Column(type: "bigint", nullable: true), 23 | time_zone = table.Column(type: "text", nullable: true), 24 | moderated = table.Column(type: "boolean", nullable: false), 25 | moderator_role = table.Column(type: "bigint", nullable: true), 26 | announce_message = table.Column(type: "text", nullable: true), 27 | announce_message_pl = table.Column(type: "text", nullable: true), 28 | announce_ping = table.Column(type: "boolean", nullable: false), 29 | last_seen = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") 30 | }, 31 | constraints: table => 32 | { 33 | table.PrimaryKey("settings_pkey", x => x.guild_id); 34 | }); 35 | 36 | migrationBuilder.CreateTable( 37 | name: "banned_users", 38 | columns: table => new 39 | { 40 | guild_id = table.Column(type: "bigint", nullable: false), 41 | user_id = table.Column(type: "bigint", nullable: false) 42 | }, 43 | constraints: table => 44 | { 45 | table.PrimaryKey("banned_users_pkey", x => new { x.guild_id, x.user_id }); 46 | table.ForeignKey( 47 | name: "banned_users_guild_id_fkey", 48 | column: x => x.guild_id, 49 | principalTable: "settings", 50 | principalColumn: "guild_id", 51 | onDelete: ReferentialAction.Cascade); 52 | }); 53 | 54 | migrationBuilder.CreateTable( 55 | name: "user_birthdays", 56 | columns: table => new 57 | { 58 | guild_id = table.Column(type: "bigint", nullable: false), 59 | user_id = table.Column(type: "bigint", nullable: false), 60 | birth_month = table.Column(type: "integer", nullable: false), 61 | birth_day = table.Column(type: "integer", nullable: false), 62 | time_zone = table.Column(type: "text", nullable: true), 63 | last_seen = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") 64 | }, 65 | constraints: table => 66 | { 67 | table.PrimaryKey("user_birthdays_pkey", x => new { x.guild_id, x.user_id }); 68 | table.ForeignKey( 69 | name: "user_birthdays_guild_id_fkey", 70 | column: x => x.guild_id, 71 | principalTable: "settings", 72 | principalColumn: "guild_id", 73 | onDelete: ReferentialAction.Cascade); 74 | }); 75 | } 76 | 77 | protected override void Down(MigrationBuilder migrationBuilder) 78 | { 79 | migrationBuilder.DropTable( 80 | name: "banned_users"); 81 | 82 | migrationBuilder.DropTable( 83 | name: "user_birthdays"); 84 | 85 | migrationBuilder.DropTable( 86 | name: "settings"); 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /ApplicationCommands/ExportModule.cs: -------------------------------------------------------------------------------- 1 | using Discord.Interactions; 2 | using System.Text; 3 | 4 | namespace BirthdayBot.ApplicationCommands; 5 | public class ExportModule : BotModuleBase { 6 | public const string HelpCmdExport = "Generates a text file with all known and available birthdays."; 7 | 8 | [SlashCommand("export-birthdays", HelpCmdExport)] 9 | [DefaultMemberPermissions(GuildPermission.ManageGuild)] 10 | [CommandContextType(InteractionContextType.Guild)] 11 | public async Task CmdExport([Summary(description: "Specify whether to export the list in CSV format.")] bool asCsv = false) { 12 | if (!await HasMemberCacheAsync(Context.Guild)) { 13 | await RespondAsync(MemberCacheEmptyError, ephemeral: true); 14 | return; 15 | } 16 | 17 | var bdlist = GetSortedUserList(Context.Guild); 18 | 19 | var filename = "birthdaybot-" + Context.Guild.Id; 20 | Stream fileoutput; 21 | if (asCsv) { 22 | fileoutput = ListExportCsv(Context.Guild, bdlist); 23 | filename += ".csv"; 24 | } else { 25 | fileoutput = ListExportNormal(Context.Guild, bdlist); 26 | filename += ".txt."; 27 | } 28 | await RespondWithFileAsync(fileoutput, filename, text: $"Exported {bdlist.Count} birthdays to file."); 29 | } 30 | 31 | private static MemoryStream ListExportNormal(SocketGuild guild, IEnumerable list) { 32 | // Output: "● Mon-dd: (user ID) Username [ - Nickname: (nickname)]" 33 | var result = new MemoryStream(); 34 | var writer = new StreamWriter(result, Encoding.UTF8); 35 | 36 | writer.WriteLine("Birthdays in " + guild.Name); 37 | writer.WriteLine(); 38 | foreach (var item in list) { 39 | var user = guild.GetUser(item.UserId); 40 | if (user == null) continue; // User disappeared in the instant between getting list and processing 41 | writer.Write($"● {Common.MonthNames[item.BirthMonth]}-{item.BirthDay:00}: "); 42 | writer.Write(item.UserId); 43 | writer.Write(" " + user.Username); 44 | if (user.DiscriminatorValue != 0) writer.Write($"#{user.Discriminator}"); 45 | if (user.GlobalName != null) writer.Write($" ({user.GlobalName})"); 46 | if (user.Nickname != null) writer.Write(" - Nickname: " + user.Nickname); 47 | if (item.TimeZone != null) writer.Write(" | Time zone: " + item.TimeZone); 48 | writer.WriteLine(); 49 | } 50 | writer.Flush(); 51 | result.Position = 0; 52 | return result; 53 | } 54 | 55 | private static MemoryStream ListExportCsv(SocketGuild guild, IEnumerable list) { 56 | // Output: User ID, Username, Nickname, Month-Day, Month, Day 57 | var result = new MemoryStream(); 58 | var writer = new StreamWriter(result, Encoding.UTF8); 59 | 60 | static string csvEscape(string input) { 61 | var result = new StringBuilder(); 62 | result.Append('"'); 63 | foreach (var ch in input) { 64 | if (ch == '"') result.Append('"'); 65 | result.Append(ch); 66 | } 67 | result.Append('"'); 68 | return result.ToString(); 69 | } 70 | 71 | // Conforming to RFC 4180; with header 72 | writer.Write("UserId,Username,DisplayName,Nickname,MonthDayDisp,Month,Day,TimeZone"); 73 | writer.Write("\r\n"); // crlf line break is specified by the standard 74 | foreach (var item in list) { 75 | var user = guild.GetUser(item.UserId); 76 | if (user == null) continue; // User disappeared in the instant between getting list and processing 77 | writer.Write(item.UserId); 78 | writer.Write(','); 79 | writer.Write(csvEscape(user.Username)); 80 | if (user.DiscriminatorValue != 0) writer.Write($"#{user.Discriminator}"); 81 | writer.Write(','); 82 | if (user.GlobalName != null) writer.Write(csvEscape(user.GlobalName)); 83 | writer.Write(','); 84 | if (user.Nickname != null) writer.Write(csvEscape(user.Nickname)); 85 | writer.Write(','); 86 | writer.Write($"{Common.MonthNames[item.BirthMonth]}-{item.BirthDay:00}"); 87 | writer.Write(','); 88 | writer.Write(item.BirthMonth); 89 | writer.Write(','); 90 | writer.Write(item.BirthDay); 91 | writer.Write(','); 92 | writer.Write(item.TimeZone); 93 | writer.Write("\r\n"); 94 | } 95 | writer.Flush(); 96 | result.Position = 0; 97 | return result; 98 | } 99 | } -------------------------------------------------------------------------------- /Data/Migrations/BotDatabaseContextModelSnapshot.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using BirthdayBot.Data; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Infrastructure; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 8 | 9 | #nullable disable 10 | 11 | namespace BirthdayBot.Data.Migrations 12 | { 13 | [DbContext(typeof(BotDatabaseContext))] 14 | partial class BotDatabaseContextModelSnapshot : ModelSnapshot 15 | { 16 | protected override void BuildModel(ModelBuilder modelBuilder) 17 | { 18 | #pragma warning disable 612, 618 19 | modelBuilder 20 | .HasAnnotation("ProductVersion", "7.0.0") 21 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 22 | 23 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 24 | 25 | modelBuilder.Entity("BirthdayBot.Data.GuildConfig", b => 26 | { 27 | b.Property("GuildId") 28 | .HasColumnType("numeric(20,0)") 29 | .HasColumnName("guild_id"); 30 | 31 | b.Property("AnnounceMessage") 32 | .HasColumnType("text") 33 | .HasColumnName("announce_message"); 34 | 35 | b.Property("AnnounceMessagePl") 36 | .HasColumnType("text") 37 | .HasColumnName("announce_message_pl"); 38 | 39 | b.Property("AnnouncePing") 40 | .HasColumnType("boolean") 41 | .HasColumnName("announce_ping"); 42 | 43 | b.Property("AnnouncementChannel") 44 | .HasColumnType("numeric(20,0)") 45 | .HasColumnName("channel_announce_id"); 46 | 47 | b.Property("BirthdayRole") 48 | .HasColumnType("numeric(20,0)") 49 | .HasColumnName("role_id"); 50 | 51 | b.Property("GuildTimeZone") 52 | .HasColumnType("text") 53 | .HasColumnName("time_zone"); 54 | 55 | b.Property("LastSeen") 56 | .ValueGeneratedOnAdd() 57 | .HasColumnType("timestamp with time zone") 58 | .HasColumnName("last_seen") 59 | .HasDefaultValueSql("now()"); 60 | 61 | b.HasKey("GuildId") 62 | .HasName("settings_pkey"); 63 | 64 | b.ToTable("settings", (string)null); 65 | }); 66 | 67 | modelBuilder.Entity("BirthdayBot.Data.UserEntry", b => 68 | { 69 | b.Property("GuildId") 70 | .HasColumnType("numeric(20,0)") 71 | .HasColumnName("guild_id"); 72 | 73 | b.Property("UserId") 74 | .HasColumnType("numeric(20,0)") 75 | .HasColumnName("user_id"); 76 | 77 | b.Property("BirthDay") 78 | .HasColumnType("integer") 79 | .HasColumnName("birth_day"); 80 | 81 | b.Property("BirthMonth") 82 | .HasColumnType("integer") 83 | .HasColumnName("birth_month"); 84 | 85 | b.Property("LastSeen") 86 | .ValueGeneratedOnAdd() 87 | .HasColumnType("timestamp with time zone") 88 | .HasColumnName("last_seen") 89 | .HasDefaultValueSql("now()"); 90 | 91 | b.Property("TimeZone") 92 | .HasColumnType("text") 93 | .HasColumnName("time_zone"); 94 | 95 | b.HasKey("GuildId", "UserId") 96 | .HasName("user_birthdays_pkey"); 97 | 98 | b.ToTable("user_birthdays", (string)null); 99 | }); 100 | 101 | modelBuilder.Entity("BirthdayBot.Data.UserEntry", b => 102 | { 103 | b.HasOne("BirthdayBot.Data.GuildConfig", "Guild") 104 | .WithMany("UserEntries") 105 | .HasForeignKey("GuildId") 106 | .OnDelete(DeleteBehavior.Cascade) 107 | .IsRequired() 108 | .HasConstraintName("user_birthdays_guild_id_fkey"); 109 | 110 | b.Navigation("Guild"); 111 | }); 112 | 113 | modelBuilder.Entity("BirthdayBot.Data.GuildConfig", b => 114 | { 115 | b.Navigation("UserEntries"); 116 | }); 117 | #pragma warning restore 612, 618 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Data/Migrations/20230204063321_RemoveModRole.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using BirthdayBot.Data; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Infrastructure; 6 | using Microsoft.EntityFrameworkCore.Migrations; 7 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 8 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 9 | 10 | #nullable disable 11 | 12 | namespace BirthdayBot.Data.Migrations 13 | { 14 | [DbContext(typeof(BotDatabaseContext))] 15 | [Migration("20230204063321_RemoveModRole")] 16 | partial class RemoveModRole 17 | { 18 | /// 19 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 20 | { 21 | #pragma warning disable 612, 618 22 | modelBuilder 23 | .HasAnnotation("ProductVersion", "7.0.0") 24 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 25 | 26 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 27 | 28 | modelBuilder.Entity("BirthdayBot.Data.GuildConfig", b => 29 | { 30 | b.Property("GuildId") 31 | .HasColumnType("numeric(20,0)") 32 | .HasColumnName("guild_id"); 33 | 34 | b.Property("AnnounceMessage") 35 | .HasColumnType("text") 36 | .HasColumnName("announce_message"); 37 | 38 | b.Property("AnnounceMessagePl") 39 | .HasColumnType("text") 40 | .HasColumnName("announce_message_pl"); 41 | 42 | b.Property("AnnouncePing") 43 | .HasColumnType("boolean") 44 | .HasColumnName("announce_ping"); 45 | 46 | b.Property("AnnouncementChannel") 47 | .HasColumnType("numeric(20,0)") 48 | .HasColumnName("channel_announce_id"); 49 | 50 | b.Property("BirthdayRole") 51 | .HasColumnType("numeric(20,0)") 52 | .HasColumnName("role_id"); 53 | 54 | b.Property("GuildTimeZone") 55 | .HasColumnType("text") 56 | .HasColumnName("time_zone"); 57 | 58 | b.Property("LastSeen") 59 | .ValueGeneratedOnAdd() 60 | .HasColumnType("timestamp with time zone") 61 | .HasColumnName("last_seen") 62 | .HasDefaultValueSql("now()"); 63 | 64 | b.HasKey("GuildId") 65 | .HasName("settings_pkey"); 66 | 67 | b.ToTable("settings", (string)null); 68 | }); 69 | 70 | modelBuilder.Entity("BirthdayBot.Data.UserEntry", b => 71 | { 72 | b.Property("GuildId") 73 | .HasColumnType("numeric(20,0)") 74 | .HasColumnName("guild_id"); 75 | 76 | b.Property("UserId") 77 | .HasColumnType("numeric(20,0)") 78 | .HasColumnName("user_id"); 79 | 80 | b.Property("BirthDay") 81 | .HasColumnType("integer") 82 | .HasColumnName("birth_day"); 83 | 84 | b.Property("BirthMonth") 85 | .HasColumnType("integer") 86 | .HasColumnName("birth_month"); 87 | 88 | b.Property("LastSeen") 89 | .ValueGeneratedOnAdd() 90 | .HasColumnType("timestamp with time zone") 91 | .HasColumnName("last_seen") 92 | .HasDefaultValueSql("now()"); 93 | 94 | b.Property("TimeZone") 95 | .HasColumnType("text") 96 | .HasColumnName("time_zone"); 97 | 98 | b.HasKey("GuildId", "UserId") 99 | .HasName("user_birthdays_pkey"); 100 | 101 | b.ToTable("user_birthdays", (string)null); 102 | }); 103 | 104 | modelBuilder.Entity("BirthdayBot.Data.UserEntry", b => 105 | { 106 | b.HasOne("BirthdayBot.Data.GuildConfig", "Guild") 107 | .WithMany("UserEntries") 108 | .HasForeignKey("GuildId") 109 | .OnDelete(DeleteBehavior.Cascade) 110 | .IsRequired() 111 | .HasConstraintName("user_birthdays_guild_id_fkey"); 112 | 113 | b.Navigation("Guild"); 114 | }); 115 | 116 | modelBuilder.Entity("BirthdayBot.Data.GuildConfig", b => 117 | { 118 | b.Navigation("UserEntries"); 119 | }); 120 | #pragma warning restore 612, 618 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /BackgroundServices/AutoUserDownload.cs: -------------------------------------------------------------------------------- 1 | using BirthdayBot.Data; 2 | using Microsoft.EntityFrameworkCore; 3 | 4 | namespace BirthdayBot.BackgroundServices; 5 | /// 6 | /// Selectively fills the user cache without overwhelming memory, database, or network resources. 7 | /// 8 | class AutoUserDownload : BackgroundService { 9 | private static readonly SemaphoreSlim _dlGate = new(3); 10 | private const int GCCallThreshold = 300; // TODO make configurable or do further testing 11 | private static readonly SemaphoreSlim _gcGate = new(1); 12 | private static int _jobCount = 0; 13 | 14 | private readonly HashSet _skippedGuilds = []; 15 | 16 | public AutoUserDownload(ShardInstance instance) : base(instance) 17 | => Shard.DiscordClient.Disconnected += OnDisconnect; 18 | 19 | private Task OnDisconnect(Exception ex) { 20 | _skippedGuilds.Clear(); 21 | return Task.CompletedTask; 22 | } 23 | 24 | public override async Task OnTick(int tickCount, CancellationToken token) { 25 | var mustFetch = await GetDownloadCandidatesAsync(token).ConfigureAwait(false); 26 | _ = await ExecDownloadListAsync(mustFetch, token).ConfigureAwait(false); 27 | } 28 | 29 | // Consider guilds with incomplete member lists that have not previously had failed downloads, 30 | // and where user-specific configuration exists. 31 | private async Task> GetDownloadCandidatesAsync(CancellationToken token) { 32 | var incompleteCaches = Shard.DiscordClient.Guilds 33 | .Where(g => !g.HasAllMembers) // Consider guilds with incomplete caches, 34 | .Where(g => !_skippedGuilds.Contains(g.Id)) // that have not previously failed during this connection, and... 35 | .Select(g => g.Id) 36 | .ToHashSet(); 37 | await DbAccessGate.WaitAsync(token).ConfigureAwait(false); 38 | try { 39 | using var db = new BotDatabaseContext(); // ...where some user data exists. 40 | return [.. db.UserEntries.AsNoTracking() 41 | .Where(e => incompleteCaches.Contains(e.GuildId)) 42 | .Select(e => e.GuildId) 43 | .Distinct()]; 44 | } finally { 45 | DbAccessGate.Release(); 46 | } 47 | } 48 | 49 | private async Task ExecDownloadListAsync(HashSet mustFetch, CancellationToken token) { 50 | var processed = 0; 51 | foreach (var item in mustFetch) { 52 | await _dlGate.WaitAsync(token).ConfigureAwait(false); 53 | try { 54 | // We're useless if not connected 55 | if (Shard.DiscordClient.ConnectionState != ConnectionState.Connected) break; 56 | 57 | SocketGuild? guild = null; 58 | guild = Shard.DiscordClient.GetGuild(item); 59 | if (guild == null) continue; // Guild disappeared between filtering and now 60 | if (guild.HasAllMembers) continue; // Download likely already invoked by user input 61 | 62 | var dl = guild.DownloadUsersAsync(); 63 | if (await Task.WhenAny(dl, Task.Delay(30_000, token)) != dl) { 64 | if (!dl.IsCompletedSuccessfully) { 65 | Log($"Task taking too long, will skip monitoring (G: {guild.Id}, U: {guild.MemberCount})."); 66 | _skippedGuilds.Add(guild.Id); 67 | continue; 68 | } 69 | } 70 | if (dl.IsFaulted) { 71 | Log("Exception thrown by download task: " + dl.Exception); 72 | break; 73 | } 74 | } finally { 75 | _dlGate.Release(); 76 | } 77 | processed++; 78 | ConsiderGC(); 79 | if (token.IsCancellationRequested) break; 80 | 81 | // This loop can last a very long time on startup. 82 | // Avoid starving other tasks. 83 | await Task.Yield(); 84 | } 85 | return processed; 86 | } 87 | 88 | // Manages manual invocation of garbage collector. 89 | // Consecutive calls to DownloadUsersAsync inevitably causes a lot of slightly-less-than-temporary items to be held by the CLR, 90 | // and this adds up with hundreds of thousands of users. Alternate methods have been explored, but this so far has proven to be 91 | // the most stable, reliable, and quickest of them. 92 | private void ConsiderGC() { 93 | if (Interlocked.Increment(ref _jobCount) > GCCallThreshold) { 94 | if (_gcGate.Wait(0)) { // prevents repeated calls across threads 95 | try { 96 | var before = GC.GetTotalMemory(forceFullCollection: false); 97 | GC.Collect(2, GCCollectionMode.Forced, true, true); 98 | var after = GC.GetTotalMemory(forceFullCollection: true); 99 | Log($"Threshold reached. GC reclaimed {before - after:N0} bytes."); 100 | Interlocked.Exchange(ref _jobCount, 0); 101 | } finally { 102 | _gcGate.Release(); 103 | } 104 | } 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Data/Migrations/20230117053251_RemoveBlocking.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using BirthdayBot.Data; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Infrastructure; 6 | using Microsoft.EntityFrameworkCore.Migrations; 7 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 8 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 9 | 10 | #nullable disable 11 | 12 | namespace BirthdayBot.Data.Migrations 13 | { 14 | [DbContext(typeof(BotDatabaseContext))] 15 | [Migration("20230117053251_RemoveBlocking")] 16 | partial class RemoveBlocking 17 | { 18 | /// 19 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 20 | { 21 | #pragma warning disable 612, 618 22 | modelBuilder 23 | .HasAnnotation("ProductVersion", "7.0.0") 24 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 25 | 26 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 27 | 28 | modelBuilder.Entity("BirthdayBot.Data.GuildConfig", b => 29 | { 30 | b.Property("GuildId") 31 | .HasColumnType("numeric(20,0)") 32 | .HasColumnName("guild_id"); 33 | 34 | b.Property("AnnounceMessage") 35 | .HasColumnType("text") 36 | .HasColumnName("announce_message"); 37 | 38 | b.Property("AnnounceMessagePl") 39 | .HasColumnType("text") 40 | .HasColumnName("announce_message_pl"); 41 | 42 | b.Property("AnnouncePing") 43 | .HasColumnType("boolean") 44 | .HasColumnName("announce_ping"); 45 | 46 | b.Property("AnnouncementChannel") 47 | .HasColumnType("numeric(20,0)") 48 | .HasColumnName("channel_announce_id"); 49 | 50 | b.Property("BirthdayRole") 51 | .HasColumnType("numeric(20,0)") 52 | .HasColumnName("role_id"); 53 | 54 | b.Property("GuildTimeZone") 55 | .HasColumnType("text") 56 | .HasColumnName("time_zone"); 57 | 58 | b.Property("LastSeen") 59 | .ValueGeneratedOnAdd() 60 | .HasColumnType("timestamp with time zone") 61 | .HasColumnName("last_seen") 62 | .HasDefaultValueSql("now()"); 63 | 64 | b.Property("Moderated") 65 | .HasColumnType("boolean") 66 | .HasColumnName("moderated"); 67 | 68 | b.Property("ModeratorRole") 69 | .HasColumnType("numeric(20,0)") 70 | .HasColumnName("moderator_role"); 71 | 72 | b.HasKey("GuildId") 73 | .HasName("settings_pkey"); 74 | 75 | b.ToTable("settings", (string)null); 76 | }); 77 | 78 | modelBuilder.Entity("BirthdayBot.Data.UserEntry", b => 79 | { 80 | b.Property("GuildId") 81 | .HasColumnType("numeric(20,0)") 82 | .HasColumnName("guild_id"); 83 | 84 | b.Property("UserId") 85 | .HasColumnType("numeric(20,0)") 86 | .HasColumnName("user_id"); 87 | 88 | b.Property("BirthDay") 89 | .HasColumnType("integer") 90 | .HasColumnName("birth_day"); 91 | 92 | b.Property("BirthMonth") 93 | .HasColumnType("integer") 94 | .HasColumnName("birth_month"); 95 | 96 | b.Property("LastSeen") 97 | .ValueGeneratedOnAdd() 98 | .HasColumnType("timestamp with time zone") 99 | .HasColumnName("last_seen") 100 | .HasDefaultValueSql("now()"); 101 | 102 | b.Property("TimeZone") 103 | .HasColumnType("text") 104 | .HasColumnName("time_zone"); 105 | 106 | b.HasKey("GuildId", "UserId") 107 | .HasName("user_birthdays_pkey"); 108 | 109 | b.ToTable("user_birthdays", (string)null); 110 | }); 111 | 112 | modelBuilder.Entity("BirthdayBot.Data.UserEntry", b => 113 | { 114 | b.HasOne("BirthdayBot.Data.GuildConfig", "Guild") 115 | .WithMany("UserEntries") 116 | .HasForeignKey("GuildId") 117 | .OnDelete(DeleteBehavior.Cascade) 118 | .IsRequired() 119 | .HasConstraintName("user_birthdays_guild_id_fkey"); 120 | 121 | b.Navigation("Guild"); 122 | }); 123 | 124 | modelBuilder.Entity("BirthdayBot.Data.GuildConfig", b => 125 | { 126 | b.Navigation("UserEntries"); 127 | }); 128 | #pragma warning restore 612, 618 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /ApplicationCommands/TzAutocompleteHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.ObjectModel; 2 | using BirthdayBot.Data; 3 | using Discord.Interactions; 4 | using Microsoft.EntityFrameworkCore; 5 | using NodaTime; 6 | 7 | namespace BirthdayBot.ApplicationCommands; 8 | public class TzAutocompleteHandler : AutocompleteHandler { 9 | private static readonly TimeSpan _maxListAge = TimeSpan.FromHours(6); 10 | private static readonly ReaderWriterLockSlim _lock = new(); 11 | private static ReadOnlyCollection _baseZonesList; 12 | private static DateTimeOffset _lastListUpdate; 13 | 14 | static TzAutocompleteHandler() { 15 | _baseZonesList = RebuildSuggestionBaseList(); 16 | _lastListUpdate = DateTimeOffset.UtcNow; 17 | } 18 | 19 | private static ReadOnlyCollection RebuildSuggestionBaseList() { 20 | // This bot discourages use of certain zone names and prefer the typical Region/City format over individual countries. 21 | // They have been excluded from this autocomplete list. 22 | var canonicalZones = DateTimeZoneProviders.Tzdb.Ids 23 | .Where(z => z.StartsWith("Africa/") 24 | || z.StartsWith("America/") 25 | || z.StartsWith("Antarctica/") // yep 26 | || z.StartsWith("Asia/") 27 | || z.StartsWith("Atlantic/") 28 | || z.StartsWith("Australia/") 29 | || z.StartsWith("Europe/") 30 | || z.StartsWith("Indian/") 31 | || z.StartsWith("Pacific/") 32 | || z.StartsWith("Etc/") 33 | || z == "UTC" 34 | || z == "GMT") 35 | .Distinct() 36 | .ToHashSet(); 37 | 38 | // List of zones by current popularity 39 | var db = new BotDatabaseContext(); 40 | var tzPopCount = db.UserEntries.AsNoTracking() 41 | .GroupBy(u => u.TimeZone) 42 | .Select(g => new { ZoneName = g.Key, Count = g.Count() }) 43 | .ToList(); 44 | 45 | // Left join: left = all NodaTime canonical zones, right = zones plus popularity data 46 | var withAllZones = canonicalZones.GroupJoin(tzPopCount, 47 | left => left, 48 | right => right.ZoneName, 49 | (tz, group) => new { 50 | ZoneName = tz, 51 | Count = group.FirstOrDefault()?.Count ?? 0 52 | }) 53 | .ToList(); 54 | 55 | // Remove all non-canonical zones, sort by popularity 56 | return withAllZones 57 | .Where(z => canonicalZones.Contains(z.ZoneName)) 58 | .OrderByDescending(z => z.Count) 59 | .Select(z => z.ZoneName) 60 | .ToList() 61 | .AsReadOnly(); 62 | } 63 | 64 | private static ReadOnlyCollection GetBaseList() { 65 | _lock.EnterUpgradeableReadLock(); 66 | try { 67 | // Should regenerate base list? 68 | var now = DateTimeOffset.UtcNow; 69 | if (now - _lastListUpdate > _maxListAge) { 70 | _lock.EnterWriteLock(); 71 | try { 72 | // Double-check in the write thread - in case another took the write lock just before us 73 | if (now - _lastListUpdate > _maxListAge) { 74 | _baseZonesList = RebuildSuggestionBaseList(); 75 | _lastListUpdate = now; 76 | } 77 | } finally { 78 | _lock.ExitWriteLock(); 79 | } 80 | } 81 | return _baseZonesList; 82 | } finally { 83 | _lock.ExitUpgradeableReadLock(); 84 | } 85 | } 86 | 87 | public override Task GenerateSuggestionsAsync(IInteractionContext cx, 88 | IAutocompleteInteraction ia, IParameterInfo pm, 89 | IServiceProvider sv) { 90 | var resultList = GetBaseList(); 91 | 92 | // Filter from existing input, give results 93 | var input = ((SocketAutocompleteInteraction)ia).Data.Current.Value.ToString()!; 94 | var inputsplit = input.Split('/', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); 95 | var result = resultList 96 | .Where(r => { 97 | var tzsplit = r.Split('/', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); 98 | if (inputsplit.Length == 2) { 99 | if (tzsplit.Length == 1) return false; 100 | return tzsplit[0].Contains(inputsplit[0], StringComparison.OrdinalIgnoreCase) 101 | && tzsplit[1].Contains(inputsplit[1], StringComparison.OrdinalIgnoreCase); 102 | } else { 103 | // No '/' in query - search for string within each side of zone name 104 | // Testing confirms this does not give conflicting results 105 | if (tzsplit.Length == 1) return tzsplit[0].Contains(input, StringComparison.OrdinalIgnoreCase); 106 | else return tzsplit[0].Contains(input, StringComparison.OrdinalIgnoreCase) 107 | || tzsplit[1].Contains(input, StringComparison.OrdinalIgnoreCase); 108 | } 109 | }) 110 | .Take(25) 111 | .Select(z => new AutocompleteResult(z, z)) 112 | .ToList(); 113 | return Task.FromResult(AutocompletionResult.FromSuccess(result)); 114 | } 115 | } -------------------------------------------------------------------------------- /Configuration.cs: -------------------------------------------------------------------------------- 1 | using CommandLine; 2 | using Newtonsoft.Json; 3 | using Newtonsoft.Json.Linq; 4 | using System.Diagnostics.CodeAnalysis; 5 | using System.Reflection; 6 | using System.Text.RegularExpressions; 7 | 8 | namespace BirthdayBot; 9 | /// 10 | /// Loads and holds configuration values. 11 | /// 12 | partial class Configuration { 13 | [GeneratedRegex(@"(?\d{1,3})[-,](?\d{1,3})")] 14 | private static partial Regex ShardRangeParser(); 15 | const string KeyShardRange = "ShardRange"; 16 | 17 | public string BotToken { get; } 18 | public string? DBotsToken { get; } 19 | 20 | public int ShardStart { get; } 21 | public int ShardAmount { get; } 22 | public int ShardTotal { get; } 23 | public int ShardStartInterval { get; } 24 | 25 | public string? SqlHost { get; } 26 | public string? SqlDatabase { get; } 27 | public string SqlUsername { get; } 28 | public string SqlPassword { get; } 29 | internal string SqlApplicationName { get; } 30 | 31 | /// 32 | /// Number of seconds between each time the status task runs, in seconds. 33 | /// 34 | public int StatusInterval { get; } 35 | /// 36 | /// Number of concurrent shard startups to happen on each check. 37 | /// This value also determines the maximum amount of concurrent background database operations. 38 | /// 39 | public int MaxConcurrentOperations { get; } 40 | /// 41 | /// Amount of time to wait between background task runs within each shard. 42 | /// 43 | public int BackgroundInterval { get; } 44 | /// 45 | /// Gets whether to show common connect/disconnect events and other related messages. 46 | /// This is disabled in the public instance, but it's worth keeping enabled in self-hosted bots. 47 | /// 48 | public bool LogConnectionStatus { get; init; } 49 | 50 | public Configuration() { 51 | var args = CommandLineParameters.Parse(Environment.GetCommandLineArgs()); 52 | var path = args?.ConfigFile ?? Path.GetDirectoryName(Assembly.GetEntryAssembly()!.Location) 53 | + Path.DirectorySeparatorChar + "." + Path.DirectorySeparatorChar + "settings.json"; 54 | 55 | // Looks for configuration file 56 | JObject jc; 57 | try { 58 | var conftxt = File.ReadAllText(path); 59 | jc = JObject.Parse(conftxt); 60 | } catch (Exception ex) { 61 | string pfx; 62 | if (ex is JsonException) pfx = "Unable to parse configuration: "; 63 | else pfx = "Unable to access configuration: "; 64 | 65 | throw new Exception(pfx + ex.Message, ex); 66 | } 67 | 68 | BotToken = ReadConfKey(jc, nameof(BotToken), true); 69 | DBotsToken = ReadConfKey(jc, nameof(DBotsToken), false); 70 | 71 | ShardTotal = args.ShardTotal ?? ReadConfKey(jc, nameof(ShardTotal), false) ?? 1; 72 | if (ShardTotal < 1) throw new Exception($"'{nameof(ShardTotal)}' must be a positive integer."); 73 | 74 | var shardRangeInput = args.ShardRange ?? ReadConfKey(jc, KeyShardRange, false); 75 | if (!string.IsNullOrWhiteSpace(shardRangeInput)) { 76 | var m = ShardRangeParser().Match(shardRangeInput); 77 | if (m.Success) { 78 | ShardStart = int.Parse(m.Groups["low"].Value); 79 | var high = int.Parse(m.Groups["high"].Value); 80 | ShardAmount = high - (ShardStart - 1); 81 | } else { 82 | throw new Exception($"Shard range not properly formatted in '{KeyShardRange}'."); 83 | } 84 | } else { 85 | // Default: this instance handles all shards 86 | ShardStart = 0; 87 | ShardAmount = ShardTotal; 88 | } 89 | 90 | ShardStartInterval = ReadConfKey(jc, nameof(ShardStartInterval), false) ?? 2; 91 | 92 | SqlHost = ReadConfKey(jc, nameof(SqlHost), false); 93 | SqlDatabase = ReadConfKey(jc, nameof(SqlDatabase), false); 94 | SqlUsername = ReadConfKey(jc, nameof(SqlUsername), true); 95 | SqlPassword = ReadConfKey(jc, nameof(SqlPassword), true); 96 | SqlApplicationName = $"Shard{ShardStart:00}-{ShardStart + ShardAmount - 1:00}"; 97 | 98 | StatusInterval = ReadConfKey(jc, nameof(StatusInterval), false) ?? 90; 99 | MaxConcurrentOperations = ReadConfKey(jc, nameof(MaxConcurrentOperations), false) ?? 4; 100 | BackgroundInterval = ReadConfKey(jc, nameof(BackgroundInterval), false) ?? 60; 101 | LogConnectionStatus = ReadConfKey(jc, nameof(LogConnectionStatus), false) ?? true; 102 | } 103 | 104 | private static T? ReadConfKey(JObject jc, string key, [DoesNotReturnIf(true)] bool failOnEmpty) { 105 | if (jc.ContainsKey(key)) return jc[key]!.Value(); 106 | if (failOnEmpty) throw new Exception($"'{key}' must be specified."); 107 | return default; 108 | } 109 | 110 | class CommandLineParameters { 111 | [Option('c', "config")] 112 | public string? ConfigFile { get; set; } 113 | 114 | [Option("shardtotal")] 115 | public int? ShardTotal { get; set; } 116 | 117 | [Option("shardrange")] 118 | public string? ShardRange { get; set; } 119 | 120 | public static CommandLineParameters? Parse(string[] args) { 121 | CommandLineParameters? result = null; 122 | 123 | new Parser(settings => { 124 | settings.IgnoreUnknownArguments = true; 125 | settings.AutoHelp = false; 126 | settings.AutoVersion = false; 127 | }).ParseArguments(args) 128 | .WithParsed(p => result = p) 129 | .WithNotParsed(e => { /* ignore */ }); 130 | return result; 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /ShardManager.cs: -------------------------------------------------------------------------------- 1 | global using Discord; 2 | global using Discord.WebSocket; 3 | using Discord.Interactions; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using System.Text; 6 | 7 | namespace BirthdayBot; 8 | /// 9 | /// More or less the main class for the program. Handles individual shards and provides frequent 10 | /// status reports regarding the overall health of the application. 11 | /// 12 | class ShardManager : IDisposable { 13 | /// 14 | /// A dictionary with shard IDs as its keys and shard instances as its values. 15 | /// When initialized, all keys will be created as configured. If an instance is removed, 16 | /// a key's corresponding value will temporarily become null instead of the key/value 17 | /// pair being removed. 18 | /// 19 | private readonly Dictionary _shards; 20 | 21 | private readonly Task _statusTask; 22 | private readonly CancellationTokenSource _mainCancel; 23 | 24 | internal Configuration Config { get; } 25 | 26 | public ShardManager(Configuration cfg) { 27 | var ver = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version; 28 | Log($"Birthday Bot v{ver!.ToString(3)} is starting..."); 29 | 30 | Config = cfg; 31 | 32 | // Allocate shards based on configuration 33 | _shards = []; 34 | for (var i = Config.ShardStart; i < (Config.ShardStart + Config.ShardAmount); i++) { 35 | _shards.Add(i, null); 36 | } 37 | 38 | // Start status reporting thread 39 | _mainCancel = new CancellationTokenSource(); 40 | _statusTask = Task.Factory.StartNew(StatusLoop, _mainCancel.Token, 41 | TaskCreationOptions.LongRunning, TaskScheduler.Default); 42 | } 43 | 44 | public void Dispose() { 45 | _mainCancel.Cancel(); 46 | _statusTask.Wait(10000); 47 | if (!_statusTask.IsCompleted) 48 | Log("Warning: Main thread did not cleanly finish up in time. Continuing..."); 49 | 50 | Log("Shutting down all shards..."); 51 | var shardDisposes = new List(); 52 | foreach (var item in _shards) { 53 | if (item.Value == null) continue; 54 | shardDisposes.Add(Task.Run(item.Value.Dispose)); 55 | } 56 | if (!Task.WhenAll(shardDisposes).Wait(30000)) { 57 | Log("Warning: Not all shards terminated cleanly after 30 seconds. Continuing..."); 58 | } 59 | } 60 | 61 | private void Log(string message) => Program.Log(nameof(ShardManager), message); 62 | 63 | /// 64 | /// Creates and sets up a new shard instance. 65 | /// 66 | private async Task InitializeShard(int shardId) { 67 | var clientConf = new DiscordSocketConfig() { 68 | ShardId = shardId, 69 | TotalShards = Config.ShardTotal, 70 | LogLevel = LogSeverity.Info, 71 | DefaultRetryMode = RetryMode.Retry502 | RetryMode.RetryTimeouts, 72 | GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildMembers, 73 | SuppressUnknownDispatchWarnings = true, 74 | LogGatewayIntentWarnings = false, 75 | FormatUsersInBidirectionalUnicode = false 76 | }; 77 | var services = new ServiceCollection() 78 | .AddSingleton(s => new ShardInstance(this, s)) 79 | .AddSingleton(s => new DiscordSocketClient(clientConf)) 80 | .AddSingleton(s => new InteractionService(s.GetRequiredService())) 81 | .BuildServiceProvider(); 82 | var newInstance = services.GetRequiredService(); 83 | await newInstance.StartAsync().ConfigureAwait(false); 84 | 85 | return newInstance; 86 | } 87 | 88 | public int? GetShardIdFor(ulong guildId) { 89 | foreach (var sh in _shards.Values) { 90 | if (sh == null) continue; 91 | if (sh.DiscordClient.GetGuild(guildId) != null) return sh.ShardId; 92 | } 93 | return null; 94 | } 95 | 96 | private async Task StatusLoop() { 97 | try { 98 | while (!_mainCancel.IsCancellationRequested) { 99 | var startAllowance = Config.ShardStartInterval; 100 | 101 | // Iterate through shards, create report on each 102 | var shardStatuses = new StringBuilder(); 103 | foreach (var i in _shards.Keys) { 104 | shardStatuses.Append($"Shard {i:00}: "); 105 | 106 | if (_shards[i] == null) { 107 | if (startAllowance > 0) { 108 | shardStatuses.AppendLine("Started."); 109 | _shards[i] = await InitializeShard(i).ConfigureAwait(false); 110 | startAllowance--; 111 | } else { 112 | shardStatuses.AppendLine("Awaiting start."); 113 | } 114 | continue; 115 | } 116 | 117 | var shard = _shards[i]!; 118 | var client = shard.DiscordClient; 119 | // TODO look into better connection checking options. ConnectionState is not reliable. 120 | shardStatuses.Append($"{Enum.GetName(typeof(ConnectionState), client.ConnectionState)} ({client.Latency:000}ms)."); 121 | shardStatuses.Append($" G: {client.Guilds.Count:0000},"); 122 | shardStatuses.Append($" U: {client.Guilds.Sum(s => s.Users.Count):000000},"); 123 | shardStatuses.Append($" BG: {shard.CurrentExecutingService ?? "Idle"}"); 124 | var lastRun = DateTimeOffset.UtcNow - shard.LastBackgroundRun; 125 | shardStatuses.Append($" since {Math.Floor(lastRun.TotalMinutes):00}m{lastRun.Seconds:00}s ago."); 126 | shardStatuses.AppendLine(); 127 | } 128 | Log(shardStatuses.ToString().TrimEnd()); 129 | Log($"Uptime: {Program.BotUptime}"); 130 | 131 | await Task.Delay(Config.StatusInterval * 1000, _mainCancel.Token).ConfigureAwait(false); 132 | } 133 | } catch (TaskCanceledException) { } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /Data/Migrations/20220319195852_InitialEFSetup.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using BirthdayBot.Data; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Infrastructure; 6 | using Microsoft.EntityFrameworkCore.Migrations; 7 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 8 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 9 | 10 | #nullable disable 11 | 12 | namespace BirthdayBot.Data.Migrations 13 | { 14 | [DbContext(typeof(BotDatabaseContext))] 15 | [Migration("20220319195852_InitialEFSetup")] 16 | partial class InitialEFSetup 17 | { 18 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 19 | { 20 | #pragma warning disable 612, 618 21 | modelBuilder 22 | .HasAnnotation("ProductVersion", "6.0.3") 23 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 24 | 25 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 26 | 27 | modelBuilder.Entity("BirthdayBot.Data.BlocklistEntry", b => 28 | { 29 | b.Property("GuildId") 30 | .HasColumnType("bigint") 31 | .HasColumnName("guild_id"); 32 | 33 | b.Property("UserId") 34 | .HasColumnType("bigint") 35 | .HasColumnName("user_id"); 36 | 37 | b.HasKey("GuildId", "UserId") 38 | .HasName("banned_users_pkey"); 39 | 40 | b.ToTable("banned_users", (string)null); 41 | }); 42 | 43 | modelBuilder.Entity("BirthdayBot.Data.GuildConfig", b => 44 | { 45 | b.Property("GuildId") 46 | .HasColumnType("bigint") 47 | .HasColumnName("guild_id"); 48 | 49 | b.Property("AnnounceMessage") 50 | .HasColumnType("text") 51 | .HasColumnName("announce_message"); 52 | 53 | b.Property("AnnounceMessagePl") 54 | .HasColumnType("text") 55 | .HasColumnName("announce_message_pl"); 56 | 57 | b.Property("AnnouncePing") 58 | .HasColumnType("boolean") 59 | .HasColumnName("announce_ping"); 60 | 61 | b.Property("ChannelAnnounceId") 62 | .HasColumnType("bigint") 63 | .HasColumnName("channel_announce_id"); 64 | 65 | b.Property("LastSeen") 66 | .ValueGeneratedOnAdd() 67 | .HasColumnType("timestamp with time zone") 68 | .HasColumnName("last_seen") 69 | .HasDefaultValueSql("now()"); 70 | 71 | b.Property("Moderated") 72 | .HasColumnType("boolean") 73 | .HasColumnName("moderated"); 74 | 75 | b.Property("ModeratorRole") 76 | .HasColumnType("bigint") 77 | .HasColumnName("moderator_role"); 78 | 79 | b.Property("RoleId") 80 | .HasColumnType("bigint") 81 | .HasColumnName("role_id"); 82 | 83 | b.Property("TimeZone") 84 | .HasColumnType("text") 85 | .HasColumnName("time_zone"); 86 | 87 | b.HasKey("GuildId") 88 | .HasName("settings_pkey"); 89 | 90 | b.ToTable("settings", (string)null); 91 | }); 92 | 93 | modelBuilder.Entity("BirthdayBot.Data.UserEntry", b => 94 | { 95 | b.Property("GuildId") 96 | .HasColumnType("bigint") 97 | .HasColumnName("guild_id"); 98 | 99 | b.Property("UserId") 100 | .HasColumnType("bigint") 101 | .HasColumnName("user_id"); 102 | 103 | b.Property("BirthDay") 104 | .HasColumnType("integer") 105 | .HasColumnName("birth_day"); 106 | 107 | b.Property("BirthMonth") 108 | .HasColumnType("integer") 109 | .HasColumnName("birth_month"); 110 | 111 | b.Property("LastSeen") 112 | .ValueGeneratedOnAdd() 113 | .HasColumnType("timestamp with time zone") 114 | .HasColumnName("last_seen") 115 | .HasDefaultValueSql("now()"); 116 | 117 | b.Property("TimeZone") 118 | .HasColumnType("text") 119 | .HasColumnName("time_zone"); 120 | 121 | b.HasKey("GuildId", "UserId") 122 | .HasName("user_birthdays_pkey"); 123 | 124 | b.ToTable("user_birthdays", (string)null); 125 | }); 126 | 127 | modelBuilder.Entity("BirthdayBot.Data.BlocklistEntry", b => 128 | { 129 | b.HasOne("BirthdayBot.Data.GuildConfig", "Guild") 130 | .WithMany("BlockedUsers") 131 | .HasForeignKey("GuildId") 132 | .OnDelete(DeleteBehavior.Cascade) 133 | .IsRequired() 134 | .HasConstraintName("banned_users_guild_id_fkey"); 135 | 136 | b.Navigation("Guild"); 137 | }); 138 | 139 | modelBuilder.Entity("BirthdayBot.Data.UserEntry", b => 140 | { 141 | b.HasOne("BirthdayBot.Data.GuildConfig", "Guild") 142 | .WithMany("UserEntries") 143 | .HasForeignKey("GuildId") 144 | .OnDelete(DeleteBehavior.Cascade) 145 | .IsRequired() 146 | .HasConstraintName("user_birthdays_guild_id_fkey"); 147 | 148 | b.Navigation("Guild"); 149 | }); 150 | 151 | modelBuilder.Entity("BirthdayBot.Data.GuildConfig", b => 152 | { 153 | b.Navigation("BlockedUsers"); 154 | 155 | b.Navigation("UserEntries"); 156 | }); 157 | #pragma warning restore 612, 618 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /Data/Migrations/20221123062847_LongToUlong.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using BirthdayBot.Data; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Infrastructure; 6 | using Microsoft.EntityFrameworkCore.Migrations; 7 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 8 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 9 | 10 | #nullable disable 11 | 12 | namespace BirthdayBot.Data.Migrations 13 | { 14 | [DbContext(typeof(BotDatabaseContext))] 15 | [Migration("20221123062847_LongToUlong")] 16 | partial class LongToUlong 17 | { 18 | /// 19 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 20 | { 21 | #pragma warning disable 612, 618 22 | modelBuilder 23 | .HasAnnotation("ProductVersion", "7.0.0") 24 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 25 | 26 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 27 | 28 | modelBuilder.Entity("BirthdayBot.Data.BlocklistEntry", b => 29 | { 30 | b.Property("GuildId") 31 | .HasColumnType("numeric(20,0)") 32 | .HasColumnName("guild_id"); 33 | 34 | b.Property("UserId") 35 | .HasColumnType("numeric(20,0)") 36 | .HasColumnName("user_id"); 37 | 38 | b.HasKey("GuildId", "UserId") 39 | .HasName("banned_users_pkey"); 40 | 41 | b.ToTable("banned_users", (string)null); 42 | }); 43 | 44 | modelBuilder.Entity("BirthdayBot.Data.GuildConfig", b => 45 | { 46 | b.Property("GuildId") 47 | .HasColumnType("numeric(20,0)") 48 | .HasColumnName("guild_id"); 49 | 50 | b.Property("AnnounceMessage") 51 | .HasColumnType("text") 52 | .HasColumnName("announce_message"); 53 | 54 | b.Property("AnnounceMessagePl") 55 | .HasColumnType("text") 56 | .HasColumnName("announce_message_pl"); 57 | 58 | b.Property("AnnouncePing") 59 | .HasColumnType("boolean") 60 | .HasColumnName("announce_ping"); 61 | 62 | b.Property("AnnouncementChannel") 63 | .HasColumnType("numeric(20,0)") 64 | .HasColumnName("channel_announce_id"); 65 | 66 | b.Property("BirthdayRole") 67 | .HasColumnType("numeric(20,0)") 68 | .HasColumnName("role_id"); 69 | 70 | b.Property("GuildTimeZone") 71 | .HasColumnType("text") 72 | .HasColumnName("time_zone"); 73 | 74 | b.Property("LastSeen") 75 | .ValueGeneratedOnAdd() 76 | .HasColumnType("timestamp with time zone") 77 | .HasColumnName("last_seen") 78 | .HasDefaultValueSql("now()"); 79 | 80 | b.Property("Moderated") 81 | .HasColumnType("boolean") 82 | .HasColumnName("moderated"); 83 | 84 | b.Property("ModeratorRole") 85 | .HasColumnType("numeric(20,0)") 86 | .HasColumnName("moderator_role"); 87 | 88 | b.HasKey("GuildId") 89 | .HasName("settings_pkey"); 90 | 91 | b.ToTable("settings", (string)null); 92 | }); 93 | 94 | modelBuilder.Entity("BirthdayBot.Data.UserEntry", b => 95 | { 96 | b.Property("GuildId") 97 | .HasColumnType("numeric(20,0)") 98 | .HasColumnName("guild_id"); 99 | 100 | b.Property("UserId") 101 | .HasColumnType("numeric(20,0)") 102 | .HasColumnName("user_id"); 103 | 104 | b.Property("BirthDay") 105 | .HasColumnType("integer") 106 | .HasColumnName("birth_day"); 107 | 108 | b.Property("BirthMonth") 109 | .HasColumnType("integer") 110 | .HasColumnName("birth_month"); 111 | 112 | b.Property("LastSeen") 113 | .ValueGeneratedOnAdd() 114 | .HasColumnType("timestamp with time zone") 115 | .HasColumnName("last_seen") 116 | .HasDefaultValueSql("now()"); 117 | 118 | b.Property("TimeZone") 119 | .HasColumnType("text") 120 | .HasColumnName("time_zone"); 121 | 122 | b.HasKey("GuildId", "UserId") 123 | .HasName("user_birthdays_pkey"); 124 | 125 | b.ToTable("user_birthdays", (string)null); 126 | }); 127 | 128 | modelBuilder.Entity("BirthdayBot.Data.BlocklistEntry", b => 129 | { 130 | b.HasOne("BirthdayBot.Data.GuildConfig", "Guild") 131 | .WithMany("BlockedUsers") 132 | .HasForeignKey("GuildId") 133 | .OnDelete(DeleteBehavior.Cascade) 134 | .IsRequired() 135 | .HasConstraintName("banned_users_guild_id_fkey"); 136 | 137 | b.Navigation("Guild"); 138 | }); 139 | 140 | modelBuilder.Entity("BirthdayBot.Data.UserEntry", b => 141 | { 142 | b.HasOne("BirthdayBot.Data.GuildConfig", "Guild") 143 | .WithMany("UserEntries") 144 | .HasForeignKey("GuildId") 145 | .OnDelete(DeleteBehavior.Cascade) 146 | .IsRequired() 147 | .HasConstraintName("user_birthdays_guild_id_fkey"); 148 | 149 | b.Navigation("Guild"); 150 | }); 151 | 152 | modelBuilder.Entity("BirthdayBot.Data.GuildConfig", b => 153 | { 154 | b.Navigation("BlockedUsers"); 155 | 156 | b.Navigation("UserEntries"); 157 | }); 158 | #pragma warning restore 612, 618 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /Data/Migrations/20221123062847_LongToUlong.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace BirthdayBot.Data.Migrations 6 | { 7 | /// 8 | public partial class LongToUlong : Migration 9 | { 10 | /// 11 | protected override void Up(MigrationBuilder migrationBuilder) 12 | { 13 | // NOTE: manually edited - must drop and re-add foreign key due to altered types 14 | migrationBuilder.DropForeignKey( 15 | name: "user_birthdays_guild_id_fkey", 16 | table: "user_birthdays"); 17 | 18 | migrationBuilder.AlterColumn( 19 | name: "user_id", 20 | table: "user_birthdays", 21 | type: "numeric(20,0)", 22 | nullable: false, 23 | oldClrType: typeof(long), 24 | oldType: "bigint"); 25 | 26 | migrationBuilder.AlterColumn( 27 | name: "guild_id", 28 | table: "user_birthdays", 29 | type: "numeric(20,0)", 30 | nullable: false, 31 | oldClrType: typeof(long), 32 | oldType: "bigint"); 33 | 34 | migrationBuilder.AlterColumn( 35 | name: "role_id", 36 | table: "settings", 37 | type: "numeric(20,0)", 38 | nullable: true, 39 | oldClrType: typeof(long), 40 | oldType: "bigint", 41 | oldNullable: true); 42 | 43 | migrationBuilder.AlterColumn( 44 | name: "moderator_role", 45 | table: "settings", 46 | type: "numeric(20,0)", 47 | nullable: true, 48 | oldClrType: typeof(long), 49 | oldType: "bigint", 50 | oldNullable: true); 51 | 52 | migrationBuilder.AlterColumn( 53 | name: "channel_announce_id", 54 | table: "settings", 55 | type: "numeric(20,0)", 56 | nullable: true, 57 | oldClrType: typeof(long), 58 | oldType: "bigint", 59 | oldNullable: true); 60 | 61 | migrationBuilder.AlterColumn( 62 | name: "guild_id", 63 | table: "settings", 64 | type: "numeric(20,0)", 65 | nullable: false, 66 | oldClrType: typeof(long), 67 | oldType: "bigint"); 68 | 69 | migrationBuilder.AlterColumn( 70 | name: "user_id", 71 | table: "banned_users", 72 | type: "numeric(20,0)", 73 | nullable: false, 74 | oldClrType: typeof(long), 75 | oldType: "bigint"); 76 | 77 | migrationBuilder.AlterColumn( 78 | name: "guild_id", 79 | table: "banned_users", 80 | type: "numeric(20,0)", 81 | nullable: false, 82 | oldClrType: typeof(long), 83 | oldType: "bigint"); 84 | 85 | migrationBuilder.AddForeignKey( 86 | name: "user_birthdays_guild_id_fkey", 87 | table: "user_birthdays", 88 | column: "guild_id", 89 | principalTable: "settings", 90 | principalColumn: "guild_id", 91 | onDelete: ReferentialAction.Cascade); 92 | } 93 | 94 | /// 95 | protected override void Down(MigrationBuilder migrationBuilder) 96 | { 97 | migrationBuilder.DropForeignKey( 98 | name: "user_birthdays_guild_id_fkey", 99 | table: "user_birthdays"); 100 | 101 | migrationBuilder.AlterColumn( 102 | name: "user_id", 103 | table: "user_birthdays", 104 | type: "bigint", 105 | nullable: false, 106 | oldClrType: typeof(decimal), 107 | oldType: "numeric(20,0)"); 108 | 109 | migrationBuilder.AlterColumn( 110 | name: "guild_id", 111 | table: "user_birthdays", 112 | type: "bigint", 113 | nullable: false, 114 | oldClrType: typeof(decimal), 115 | oldType: "numeric(20,0)"); 116 | 117 | migrationBuilder.AlterColumn( 118 | name: "role_id", 119 | table: "settings", 120 | type: "bigint", 121 | nullable: true, 122 | oldClrType: typeof(decimal), 123 | oldType: "numeric(20,0)", 124 | oldNullable: true); 125 | 126 | migrationBuilder.AlterColumn( 127 | name: "moderator_role", 128 | table: "settings", 129 | type: "bigint", 130 | nullable: true, 131 | oldClrType: typeof(decimal), 132 | oldType: "numeric(20,0)", 133 | oldNullable: true); 134 | 135 | migrationBuilder.AlterColumn( 136 | name: "channel_announce_id", 137 | table: "settings", 138 | type: "bigint", 139 | nullable: true, 140 | oldClrType: typeof(decimal), 141 | oldType: "numeric(20,0)", 142 | oldNullable: true); 143 | 144 | migrationBuilder.AlterColumn( 145 | name: "guild_id", 146 | table: "settings", 147 | type: "bigint", 148 | nullable: false, 149 | oldClrType: typeof(decimal), 150 | oldType: "numeric(20,0)"); 151 | 152 | migrationBuilder.AlterColumn( 153 | name: "user_id", 154 | table: "banned_users", 155 | type: "bigint", 156 | nullable: false, 157 | oldClrType: typeof(decimal), 158 | oldType: "numeric(20,0)"); 159 | 160 | migrationBuilder.AlterColumn( 161 | name: "guild_id", 162 | table: "banned_users", 163 | type: "bigint", 164 | nullable: false, 165 | oldClrType: typeof(decimal), 166 | oldType: "numeric(20,0)"); 167 | 168 | migrationBuilder.AddForeignKey( 169 | name: "user_birthdays_guild_id_fkey", 170 | table: "user_birthdays", 171 | column: "guild_id", 172 | principalTable: "settings", 173 | principalColumn: "guild_id", 174 | onDelete: ReferentialAction.Cascade); 175 | } 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /ShardInstance.cs: -------------------------------------------------------------------------------- 1 | using BirthdayBot.ApplicationCommands; 2 | using BirthdayBot.BackgroundServices; 3 | using Discord.Interactions; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using System.Reflection; 6 | 7 | namespace BirthdayBot; 8 | /// 9 | /// Single shard instance for Birthday Bot. This shard independently handles all input and output to Discord. 10 | /// 11 | public sealed class ShardInstance : IDisposable { 12 | private readonly ShardManager _manager; 13 | private readonly ShardBackgroundWorker _background; 14 | private readonly InteractionService _interactionService; 15 | private readonly IServiceProvider _services; 16 | 17 | internal DiscordSocketClient DiscordClient { get; } 18 | public int ShardId => DiscordClient.ShardId; 19 | /// 20 | /// Returns a value showing the time in which the last background run successfully completed. 21 | /// 22 | internal DateTimeOffset LastBackgroundRun => _background.LastBackgroundRun; 23 | /// 24 | /// Returns the name of the background service currently in execution. 25 | /// 26 | internal string? CurrentExecutingService => _background.CurrentExecutingService; 27 | internal Configuration Config => _manager.Config; 28 | 29 | public const string InternalError = ":x: An unknown error occurred. If it persists, please notify the bot owner."; 30 | 31 | /// 32 | /// Prepares and configures the shard instances, but does not yet start its connection. 33 | /// 34 | internal ShardInstance(ShardManager manager, IServiceProvider services) { 35 | _manager = manager; 36 | _services = services; 37 | 38 | DiscordClient = _services.GetRequiredService(); 39 | DiscordClient.Log += Client_Log; 40 | DiscordClient.Ready += Client_Ready; 41 | 42 | _interactionService = _services.GetRequiredService(); 43 | DiscordClient.InteractionCreated += DiscordClient_InteractionCreated; 44 | _interactionService.SlashCommandExecuted += InteractionService_SlashCommandExecuted; 45 | DiscordClient.ModalSubmitted += modal => { return ModalResponder.DiscordClient_ModalSubmitted(this, modal); }; 46 | 47 | // Background task constructor begins background processing immediately. 48 | _background = new ShardBackgroundWorker(this); 49 | } 50 | 51 | /// 52 | /// Starts up this shard's connection to Discord and background task handling associated with it. 53 | /// 54 | public async Task StartAsync() { 55 | await _interactionService.AddModulesAsync(Assembly.GetExecutingAssembly(), _services).ConfigureAwait(false); 56 | await DiscordClient.LoginAsync(TokenType.Bot, Config.BotToken).ConfigureAwait(false); 57 | await DiscordClient.StartAsync().ConfigureAwait(false); 58 | } 59 | 60 | /// 61 | /// Does all necessary steps to stop this shard, including canceling background tasks and disconnecting. 62 | /// 63 | public void Dispose() { 64 | _background.Dispose(); 65 | DiscordClient.LogoutAsync().Wait(5000); 66 | DiscordClient.Dispose(); 67 | _interactionService.Dispose(); 68 | } 69 | 70 | internal void Log(string source, string message) => Program.Log($"Shard {ShardId:00}] [{source}", message); 71 | 72 | private Task Client_Log(LogMessage arg) { 73 | // Suppress certain messages 74 | if (arg.Message != null) { 75 | if (!_manager.Config.LogConnectionStatus) { 76 | switch (arg.Message) { 77 | case "Connecting": 78 | case "Connected": 79 | case "Ready": 80 | case "Disconnecting": 81 | case "Disconnected": 82 | case "Resumed previous session": 83 | case "Failed to resume previous session": 84 | case "Serializer Error": // The exception associated with this log appears a lot as of v3.2-ish 85 | return Task.CompletedTask; 86 | } 87 | } 88 | Log("Discord.Net", $"{arg.Severity}: {arg.Message}"); 89 | } 90 | 91 | if (arg.Exception != null) { 92 | if (!_manager.Config.LogConnectionStatus) { 93 | if (arg.Exception is GatewayReconnectException || arg.Exception.Message == "WebSocket connection was closed") 94 | return Task.CompletedTask; 95 | } 96 | 97 | if (arg.Exception is TaskCanceledException) return Task.CompletedTask; // We don't ever need to know these... 98 | Log("Discord.Net exception", $"{arg.Exception.GetType().FullName}: {arg.Exception.Message}"); 99 | } 100 | 101 | return Task.CompletedTask; 102 | } 103 | 104 | private async Task Client_Ready() { 105 | #if !DEBUG 106 | // Update slash/interaction commands 107 | if (ShardId == 0) { 108 | await _interactionService.RegisterCommandsGloballyAsync(true); 109 | Log(nameof(ShardInstance), "Updated global command registration."); 110 | } 111 | #else 112 | // Debug: Register our commands locally instead, in each guild we're in 113 | if (DiscordClient.Guilds.Count > 5) { 114 | Program.Log(nameof(ShardInstance), "Are you debugging in production?! Skipping DEBUG command registration."); 115 | return; 116 | } else { 117 | foreach (var g in DiscordClient.Guilds) { 118 | await _interactionService.RegisterCommandsToGuildAsync(g.Id, true).ConfigureAwait(false); 119 | Log(nameof(ShardInstance), $"Updated DEBUG command registration in guild {g.Id}."); 120 | } 121 | } 122 | #endif 123 | } 124 | 125 | // Slash command preparation and invocation 126 | private async Task DiscordClient_InteractionCreated(SocketInteraction arg) { 127 | var context = new SocketInteractionContext(DiscordClient, arg); 128 | 129 | try { 130 | await _interactionService.ExecuteCommandAsync(context, _services).ConfigureAwait(false); 131 | } catch (Exception e) { 132 | Log(nameof(DiscordClient_InteractionCreated), $"Unhandled exception. {e}"); 133 | if (arg.Type == InteractionType.ApplicationCommand) { 134 | if (arg.HasResponded) await arg.ModifyOriginalResponseAsync(prop => prop.Content = InternalError); 135 | else await arg.RespondAsync(InternalError); 136 | } 137 | } 138 | } 139 | 140 | // Slash command logging and failed execution handling 141 | private Task InteractionService_SlashCommandExecuted(SlashCommandInfo info, IInteractionContext context, IResult result) { 142 | string sender; 143 | if (context.Guild != null) sender = $"{context.Guild}!{context.User}"; 144 | else sender = $"{context.User} in non-guild context"; 145 | var logresult = $"{(result.IsSuccess ? "Success" : "Fail")}: `/{info}` by {sender}."; 146 | 147 | if (result.Error != null) { 148 | // Additional log information with error detail 149 | logresult += " " + Enum.GetName(typeof(InteractionCommandError), result.Error) + ": " + result.ErrorReason; 150 | } 151 | 152 | Log("Command", logresult); 153 | return Task.CompletedTask; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /ApplicationCommands/BotModuleBase.cs: -------------------------------------------------------------------------------- 1 | using BirthdayBot.Data; 2 | using Discord.Interactions; 3 | using NodaTime; 4 | using System.Collections.ObjectModel; 5 | using System.Diagnostics.CodeAnalysis; 6 | using System.Text.RegularExpressions; 7 | 8 | namespace BirthdayBot.ApplicationCommands; 9 | 10 | /// 11 | /// Base class for our interaction module classes. Contains common data for use in implementing classes. 12 | /// 13 | public abstract partial class BotModuleBase : InteractionModuleBase { 14 | protected const string MemberCacheEmptyError = ":warning: Please try the command again."; 15 | public const string AccessDeniedError = ":warning: You are not allowed to run this command."; 16 | 17 | protected const string HelpOptDate = "A date, including the month and day. For example, \"15 January\"."; 18 | protected const string HelpOptZone = "A 'tzdata'-compliant time zone name. See help for more details."; 19 | 20 | /// 21 | /// The corresponding handling the client where the command originated from. 22 | /// 23 | [NotNull] 24 | public ShardInstance? Shard { get; set; } 25 | 26 | protected static IReadOnlyDictionary TzNameMap { get; } 27 | 28 | static BotModuleBase() { 29 | var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); 30 | foreach (var name in DateTimeZoneProviders.Tzdb.Ids) dict.Add(name, name); 31 | TzNameMap = new ReadOnlyDictionary(dict); 32 | } 33 | 34 | /// 35 | /// Checks given time zone input. Returns a valid string for use with NodaTime, 36 | /// throwing a FormatException if the input is not recognized. 37 | /// 38 | protected static string ParseTimeZone(string tzinput) { 39 | if (!TzNameMap.TryGetValue(tzinput, out var tz)) 40 | throw new FormatException(":x: Unknown time zone name.\n" + 41 | "To find your time zone, please refer to: https://zones.arilyn.cc/"); 42 | return tz!; 43 | } 44 | 45 | /// 46 | /// An alternative to to be called by command handlers needing a full member cache. 47 | /// Creates a download request if necessary. 48 | /// 49 | /// 50 | /// True if the member cache is already filled, false otherwise. 51 | /// 52 | /// 53 | /// Any updates to the member cache aren't accessible until the event handler finishes execution, meaning proactive downloading 54 | /// is necessary, and is handled by . In situations where 55 | /// this approach fails, this is to be called, and the user must be asked to attempt the command again if this returns false. 56 | /// 57 | protected static async Task HasMemberCacheAsync(SocketGuild guild) { 58 | if (Common.HasMostMembersDownloaded(guild)) return true; 59 | // Event handling thread hangs if awaited normally or used with Task.Run 60 | await Task.Factory.StartNew(guild.DownloadUsersAsync).ConfigureAwait(false); 61 | return false; 62 | } 63 | 64 | #region Date parsing 65 | const string FormatError = ":x: Unrecognized date format. The following formats are accepted, as examples: " 66 | + "`15-jan`, `jan-15`, `15 jan`, `jan 15`, `15 January`, `January 15`."; 67 | 68 | [GeneratedRegex(@"^(?\d{1,2})[ -](?[A-Za-z]+)$")] 69 | private static partial Regex DateParser1(); 70 | [GeneratedRegex(@"^(?[A-Za-z]+)[ -](?\d{1,2})$")] 71 | private static partial Regex DateParser2(); 72 | 73 | /// 74 | /// Parses a date input. 75 | /// 76 | /// Tuple: month, day 77 | /// 78 | /// Thrown for any parsing issue. Reason is expected to be sent to Discord as-is. 79 | /// 80 | protected static (int, int) ParseDate(string dateInput) { 81 | var m = DateParser1().Match(dateInput); 82 | if (!m.Success) { 83 | // Flip the fields around, try again 84 | m = DateParser2().Match(dateInput); 85 | if (!m.Success) throw new FormatException(FormatError); 86 | } 87 | 88 | int day, month; 89 | string monthVal; 90 | try { 91 | day = int.Parse(m.Groups["day"].Value); 92 | } catch (FormatException) { 93 | throw new Exception(FormatError); 94 | } 95 | monthVal = m.Groups["month"].Value; 96 | 97 | int dayUpper; // upper day of month check 98 | (month, dayUpper) = GetMonth(monthVal); 99 | 100 | if (day == 0 || day > dayUpper) throw new FormatException(":x: The date you specified is not a valid calendar date."); 101 | 102 | return (month, day); 103 | } 104 | 105 | /// 106 | /// Returns information for a given month input. 107 | /// 108 | /// 109 | /// Tuple: Month value, upper limit of days in the month 110 | /// 111 | /// Thrown on error. Send out to Discord as-is. 112 | /// 113 | private static (int, int) GetMonth(string input) { 114 | return input.ToLower() switch { 115 | "jan" or "january" => (1, 31), 116 | "feb" or "february" => (2, 29), 117 | "mar" or "march" => (3, 31), 118 | "apr" or "april" => (4, 30), 119 | "may" => (5, 31), 120 | "jun" or "june" => (6, 30), 121 | "jul" or "july" => (7, 31), 122 | "aug" or "august" => (8, 31), 123 | "sep" or "september" => (9, 30), 124 | "oct" or "october" => (10, 31), 125 | "nov" or "november" => (11, 30), 126 | "dec" or "december" => (12, 31), 127 | _ => throw new FormatException($":x: Can't determine month name `{input}`. Check your spelling and try again."), 128 | }; 129 | } 130 | 131 | /// 132 | /// Returns a string representing a birthday in a consistent format. 133 | /// 134 | protected static string FormatDate(int month, int day) => $"{day:00}-{Common.MonthNames[month]}"; 135 | #endregion 136 | 137 | #region Listing helper methods 138 | /// 139 | /// Fetches all guild birthdays and places them into an easily usable structure. 140 | /// Users currently not in the guild are not included in the result. 141 | /// 142 | protected static List GetSortedUserList(SocketGuild guild) { 143 | using var db = new BotDatabaseContext(); 144 | var query = from row in db.UserEntries 145 | where row.GuildId == guild.Id 146 | orderby row.BirthMonth, row.BirthDay 147 | select new { 148 | row.UserId, 149 | Month = row.BirthMonth, 150 | Day = row.BirthDay, 151 | Zone = row.TimeZone 152 | }; 153 | 154 | var result = new List(); 155 | foreach (var row in query) { 156 | var guildUser = guild.GetUser(row.UserId); 157 | if (guildUser == null) continue; // Skip user not in guild 158 | 159 | result.Add(new ListItem() { 160 | BirthMonth = row.Month, 161 | BirthDay = row.Day, 162 | DateIndex = DateIndex(row.Month, row.Day), 163 | UserId = guildUser.Id, 164 | DisplayName = Common.FormatName(guildUser, false), 165 | TimeZone = row.Zone 166 | }); 167 | } 168 | return result; 169 | } 170 | 171 | protected static int DateIndex(int month, int day) { 172 | var dateindex = 0; 173 | // Add month offsets 174 | if (month > 1) dateindex += 31; // Offset January 175 | if (month > 2) dateindex += 29; // Offset February (incl. leap day) 176 | if (month > 3) dateindex += 31; // etc 177 | if (month > 4) dateindex += 30; 178 | if (month > 5) dateindex += 31; 179 | if (month > 6) dateindex += 30; 180 | if (month > 7) dateindex += 31; 181 | if (month > 8) dateindex += 31; 182 | if (month > 9) dateindex += 30; 183 | if (month > 10) dateindex += 31; 184 | if (month > 11) dateindex += 30; 185 | dateindex += day; 186 | return dateindex; 187 | } 188 | 189 | protected struct ListItem { 190 | public int DateIndex; 191 | public int BirthMonth; 192 | public int BirthDay; 193 | public ulong UserId; 194 | public string DisplayName; 195 | public string? TimeZone; 196 | } 197 | #endregion 198 | } -------------------------------------------------------------------------------- /BackgroundServices/BirthdayRoleUpdate.cs: -------------------------------------------------------------------------------- 1 | using BirthdayBot.Data; 2 | using NodaTime; 3 | using System.Text; 4 | 5 | namespace BirthdayBot.BackgroundServices; 6 | /// 7 | /// Core automatic functionality of the bot. Manages role memberships based on birthday information, 8 | /// and optionally sends the announcement message to appropriate guilds. 9 | /// 10 | class BirthdayRoleUpdate(ShardInstance instance) : BackgroundService(instance) { 11 | /// 12 | /// Processes birthday updates for all available guilds synchronously. 13 | /// 14 | public override async Task OnTick(int tickCount, CancellationToken token) { 15 | try { 16 | await DbAccessGate.WaitAsync(token).ConfigureAwait(false); 17 | await ProcessBirthdaysAsync(token).ConfigureAwait(false); 18 | } finally { 19 | try { 20 | DbAccessGate.Release(); 21 | } catch (ObjectDisposedException) { } 22 | } 23 | } 24 | 25 | private async Task ProcessBirthdaysAsync(CancellationToken token) { 26 | // For database efficiency, fetch all pertinent 'global' database information at once before proceeding 27 | using var db = new BotDatabaseContext(); 28 | var shardGuilds = Shard.DiscordClient.Guilds.Select(g => g.Id).ToHashSet(); 29 | var presentGuildSettings = db.GuildConfigurations.Where(s => shardGuilds.Contains(s.GuildId)); 30 | var guildChecks = presentGuildSettings.ToList().Select(s => Tuple.Create(s.GuildId, s)); 31 | 32 | var exceptions = new List(); 33 | foreach (var (guildId, settings) in guildChecks) { 34 | var guild = Shard.DiscordClient.GetGuild(guildId); 35 | if (guild == null) continue; // A guild disappeared...? 36 | 37 | // Check task cancellation here. Processing during a single guild is never interrupted. 38 | if (token.IsCancellationRequested) throw new TaskCanceledException(); 39 | 40 | // Stop if we've disconnected. 41 | if (Shard.DiscordClient.ConnectionState != ConnectionState.Connected) break; 42 | 43 | try { 44 | // Verify that role settings and permissions are usable 45 | SocketRole? role = guild.GetRole(settings.BirthdayRole ?? 0); 46 | if (role == null) continue; // Role not set. 47 | if (!guild.CurrentUser.GuildPermissions.ManageRoles || role.Position >= guild.CurrentUser.Hierarchy) { 48 | // Quit this guild if insufficient role permissions. 49 | continue; 50 | } 51 | if (role.IsEveryone || role.IsManaged) { 52 | // Invalid role was configured. Clear the setting and quit. 53 | settings.BirthdayRole = null; 54 | db.Update(settings); 55 | await db.SaveChangesAsync(CancellationToken.None).ConfigureAwait(false); 56 | continue; 57 | } 58 | 59 | // Load up user configs and begin processing birthdays 60 | await db.Entry(settings).Collection(t => t.UserEntries).LoadAsync(CancellationToken.None).ConfigureAwait(false); 61 | var birthdays = GetGuildCurrentBirthdays(settings.UserEntries, settings.GuildTimeZone); 62 | 63 | // Add or remove roles as appropriate 64 | var announcementList = await UpdateGuildBirthdayRoles(guild, role, birthdays).ConfigureAwait(false); 65 | 66 | // Process birthday announcement 67 | if (announcementList.Any()) { 68 | await AnnounceBirthdaysAsync(settings, guild, announcementList).ConfigureAwait(false); 69 | } 70 | } catch (Exception ex) { 71 | // Catch all exceptions per-guild but continue processing, throw at end. 72 | exceptions.Add(ex); 73 | } 74 | } 75 | if (exceptions.Count > 1) throw new AggregateException("Unhandled exceptions occurred when processing birthdays.", exceptions); 76 | else if (exceptions.Count == 1) throw new Exception("An unhandled exception occurred when processing a birthday.", exceptions[0]); 77 | } 78 | 79 | /// 80 | /// Gets all known users from the given guild and returns a list including only those who are 81 | /// currently experiencing a birthday in the respective time zone. 82 | /// 83 | public static HashSet GetGuildCurrentBirthdays(IEnumerable guildUsers, string? serverDefaultTzId) { 84 | var birthdayUsers = new HashSet(); 85 | 86 | foreach (var record in guildUsers) { 87 | // Determine final time zone to use for calculation 88 | DateTimeZone tz = DateTimeZoneProviders.Tzdb 89 | .GetZoneOrNull(record.TimeZone ?? serverDefaultTzId ?? "UTC")!; 90 | 91 | var checkNow = SystemClock.Instance.GetCurrentInstant().InZone(tz); 92 | // Special case: If user's birthday is 29-Feb and it's currently not a leap year, check against 1-Mar 93 | if (!DateTime.IsLeapYear(checkNow.Year) && record.BirthMonth == 2 && record.BirthDay == 29) { 94 | if (checkNow.Month == 3 && checkNow.Day == 1) birthdayUsers.Add(record.UserId); 95 | } else if (record.BirthMonth == checkNow.Month && record.BirthDay == checkNow.Day) { 96 | birthdayUsers.Add(record.UserId); 97 | } 98 | } 99 | 100 | return birthdayUsers; 101 | } 102 | 103 | /// 104 | /// Sets the birthday role to all applicable users. Unsets it from all others who may have it. 105 | /// 106 | /// 107 | /// List of users who had the birthday role applied, used to announce. 108 | /// 109 | private static async Task> UpdateGuildBirthdayRoles(SocketGuild g, SocketRole r, HashSet toApply) { 110 | var additions = new List(); 111 | try { 112 | var removals = new List(); 113 | var no_ops = new HashSet(); 114 | 115 | // Scan role for members no longer needing it 116 | foreach (var user in r.Members) { 117 | if (!toApply.Contains(user.Id)) removals.Add(user); 118 | else no_ops.Add(user.Id); 119 | } 120 | foreach (var user in removals) { 121 | await user.RemoveRoleAsync(r).ConfigureAwait(false); 122 | } 123 | 124 | foreach (var target in toApply) { 125 | if (no_ops.Contains(target)) continue; 126 | var user = g.GetUser(target); 127 | if (user == null) continue; // User existing in database but not in guild 128 | await user.AddRoleAsync(r).ConfigureAwait(false); 129 | additions.Add(user); 130 | } 131 | } catch (Discord.Net.HttpException ex) 132 | when (ex.DiscordCode is DiscordErrorCode.MissingPermissions or DiscordErrorCode.InsufficientPermissions) { 133 | // Encountered access and/or permission issues despite earlier checks. Quit the loop here, don't report error. 134 | } 135 | return additions; 136 | } 137 | 138 | public const string DefaultAnnounce = "Please wish a happy birthday to %n!"; 139 | public const string DefaultAnnouncePl = "Please wish a happy birthday to our esteemed members: %n"; 140 | 141 | /// 142 | /// Attempts to send an announcement message. 143 | /// 144 | public static async Task AnnounceBirthdaysAsync(GuildConfig settings, SocketGuild g, IEnumerable names) { 145 | var c = g.GetTextChannel(settings.AnnouncementChannel ?? 0); 146 | if (c == null) return; 147 | if (!c.Guild.CurrentUser.GetPermissions(c).SendMessages) return; 148 | 149 | string announceMsg; 150 | if (names.Count() == 1) announceMsg = settings.AnnounceMessage ?? settings.AnnounceMessagePl ?? DefaultAnnounce; 151 | else announceMsg = settings.AnnounceMessagePl ?? settings.AnnounceMessage ?? DefaultAnnouncePl; 152 | announceMsg = announceMsg.TrimEnd(); 153 | if (!announceMsg.Contains("%n")) announceMsg += " %n"; 154 | 155 | // Build sorted name list 156 | var namestrings = new List(); 157 | foreach (var item in names) 158 | namestrings.Add(Common.FormatName(item, settings.AnnouncePing)); 159 | namestrings.Sort(StringComparer.OrdinalIgnoreCase); 160 | 161 | var namedisplay = new StringBuilder(); 162 | foreach (var item in namestrings) { 163 | namedisplay.Append(", "); 164 | namedisplay.Append(item); 165 | } 166 | namedisplay.Remove(0, 2); // Remove initial comma and space 167 | 168 | await c.SendMessageAsync(announceMsg.Replace("%n", namedisplay.ToString())).ConfigureAwait(false); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /ApplicationCommands/BirthdayModule.cs: -------------------------------------------------------------------------------- 1 | using BirthdayBot.Data; 2 | using Discord.Interactions; 3 | using System.Text; 4 | 5 | namespace BirthdayBot.ApplicationCommands; 6 | [Group("birthday", HelpCmdBirthday)] 7 | [CommandContextType(InteractionContextType.Guild)] 8 | public class BirthdayModule : BotModuleBase { 9 | public const string HelpCmdBirthday = "Commands relating to birthdays."; 10 | public const string HelpCmdSetDate = "Sets or updates your birthday."; 11 | public const string HelpCmdSetZone = "Sets or updates your time zone if your birthday is already set."; 12 | public const string HelpCmdRemove = "Removes your birthday information from this bot."; 13 | public const string HelpCmdGet = "Gets a user's birthday."; 14 | public const string HelpCmdNearest = "Get a list of users who recently had or will have a birthday."; 15 | 16 | [Group("set", "Subcommands for setting birthday information.")] 17 | public class SubCmdsBirthdaySet : BotModuleBase { 18 | [SlashCommand("date", HelpCmdSetDate)] 19 | public async Task CmdSetBday([Summary(description: HelpOptDate)] string date, 20 | [Summary(description: HelpOptZone), Autocomplete] string? zone = null) { 21 | // IMPORTANT: If editing here, reflect changes as needed in BirthdayOverrideModule. 22 | int inmonth, inday; 23 | try { 24 | (inmonth, inday) = ParseDate(date); 25 | } catch (FormatException e) { 26 | // Our parse method's FormatException has its message to send out to Discord. 27 | await RespondAsync(e.Message, ephemeral: true).ConfigureAwait(false); 28 | return; 29 | } 30 | 31 | string? inzone = null; 32 | if (zone != null) { 33 | try { 34 | inzone = ParseTimeZone(zone); 35 | } catch (FormatException e) { 36 | await ReplyAsync(e.Message).ConfigureAwait(false); 37 | return; 38 | } 39 | } 40 | 41 | using var db = new BotDatabaseContext(); 42 | var guild = ((SocketTextChannel)Context.Channel).Guild.GetConfigOrNew(db); 43 | if (guild.IsNew) db.GuildConfigurations.Add(guild); // Satisfy foreign key constraint 44 | var user = ((SocketGuildUser)Context.User).GetUserEntryOrNew(db); 45 | if (user.IsNew) db.UserEntries.Add(user); 46 | user.BirthMonth = inmonth; 47 | user.BirthDay = inday; 48 | user.TimeZone = inzone ?? user.TimeZone; 49 | await db.SaveChangesAsync(); 50 | 51 | var response = $":white_check_mark: Your birthday has been set to **{FormatDate(inmonth, inday)}**"; 52 | if (inzone != null) response += $" at time zone **{inzone}**"; 53 | response += "."; 54 | if (user.TimeZone == null) 55 | response += "\n(Tip: The `/birthday set timezone` command ensures your birthday is recognized just in time!)"; 56 | await RespondAsync(response).ConfigureAwait(false); 57 | } 58 | 59 | [SlashCommand("timezone", HelpCmdSetZone)] 60 | public async Task CmdSetZone([Summary(description: HelpOptZone), Autocomplete] string zone) { 61 | using var db = new BotDatabaseContext(); 62 | 63 | var user = ((SocketGuildUser)Context.User).GetUserEntryOrNew(db); 64 | if (user.IsNew) { 65 | await RespondAsync(":x: You do not have a birthday set.", ephemeral: true).ConfigureAwait(false); 66 | return; 67 | } 68 | 69 | string newzone; 70 | try { 71 | newzone = ParseTimeZone(zone); 72 | } catch (FormatException e) { 73 | await RespondAsync(e.Message, ephemeral: true).ConfigureAwait(false); 74 | return; 75 | } 76 | user.TimeZone = newzone; 77 | await db.SaveChangesAsync(); 78 | await RespondAsync($":white_check_mark: Your time zone has been set to **{newzone}**.").ConfigureAwait(false); 79 | } 80 | } 81 | 82 | [SlashCommand("remove", HelpCmdRemove)] 83 | public async Task CmdRemove() { 84 | using var db = new BotDatabaseContext(); 85 | var user = ((SocketGuildUser)Context.User).GetUserEntryOrNew(db); 86 | if (!user.IsNew) { 87 | db.UserEntries.Remove(user); 88 | await db.SaveChangesAsync(); 89 | await RespondAsync(":white_check_mark: Your birthday in this server has been removed."); 90 | } else { 91 | await RespondAsync(":white_check_mark: Your birthday is not registered.") 92 | .ConfigureAwait(false); 93 | } 94 | } 95 | 96 | [SlashCommand("get", "Gets a user's birthday.")] 97 | public async Task CmdGetBday([Summary(description: "Optional: The user's birthday to look up.")] SocketGuildUser? user = null) { 98 | using var db = new BotDatabaseContext(); 99 | 100 | var isSelf = user is null; 101 | if (isSelf) user = (SocketGuildUser)Context.User; 102 | 103 | var targetdata = user!.GetUserEntryOrNew(db); 104 | 105 | if (targetdata.IsNew) { 106 | if (isSelf) await RespondAsync(":x: You do not have your birthday registered.", ephemeral: true).ConfigureAwait(false); 107 | else await RespondAsync(":x: The given user does not have their birthday registered.", ephemeral: true).ConfigureAwait(false); 108 | return; 109 | } 110 | 111 | await RespondAsync($"{Common.FormatName(user!, false)}: `{FormatDate(targetdata.BirthMonth, targetdata.BirthDay)}`" + 112 | (targetdata.TimeZone == null ? "" : $" - {targetdata.TimeZone}")).ConfigureAwait(false); 113 | } 114 | 115 | // "Recent and upcoming birthdays" 116 | // The 'recent' bit removes time zone ambiguity and spares us from extra time zone processing here 117 | // TODO stop being lazy 118 | [SlashCommand("show-nearest", HelpCmdNearest)] 119 | public async Task CmdShowNearest() { 120 | if (!await HasMemberCacheAsync(Context.Guild).ConfigureAwait(false)) { 121 | await RespondAsync(MemberCacheEmptyError, ephemeral: true).ConfigureAwait(false); 122 | return; 123 | } 124 | 125 | var now = DateTimeOffset.UtcNow; 126 | var search = DateIndex(now.Month, now.Day) - 8; // begin search 8 days prior to current date UTC 127 | if (search <= 0) search = 366 - Math.Abs(search); 128 | 129 | var query = GetSortedUserList(Context.Guild); 130 | 131 | // TODO pagination instead of this workaround 132 | var hasOutputOneLine = false; 133 | // First output is shown as an interaction response, followed then as regular channel messages 134 | async Task doOutput(string msg) { 135 | if (!hasOutputOneLine) { 136 | await RespondAsync(msg).ConfigureAwait(false); 137 | hasOutputOneLine = true; 138 | } else { 139 | await ReplyAsync(msg).ConfigureAwait(false); 140 | } 141 | } 142 | 143 | var output = new StringBuilder(); 144 | var resultCount = 0; 145 | output.AppendLine("Recent and upcoming birthdays:"); 146 | for (var count = 0; count <= 21; count++) { // cover 21 days total (7 prior, current day, 14 upcoming) 147 | var results = from item in query 148 | where item.DateIndex == search 149 | select item; 150 | 151 | // push up search by 1 now, in case we back out early 152 | search += 1; 153 | if (search > 366) search = 1; // wrap to beginning of year 154 | 155 | if (!results.Any()) continue; // back out early 156 | resultCount += results.Count(); 157 | 158 | // Build sorted name list 159 | var names = new List(); 160 | foreach (var item in results) { 161 | names.Add(item.DisplayName); 162 | } 163 | names.Sort(StringComparer.OrdinalIgnoreCase); 164 | 165 | var first = true; 166 | output.AppendLine(); 167 | output.Append($"● `{Common.MonthNames[results.First().BirthMonth]}-{results.First().BirthDay:00}`: "); 168 | foreach (var item in names) { 169 | // If the output is starting to fill up, send out this message and prepare a new one. 170 | if (output.Length > 800) { 171 | await doOutput(output.ToString()).ConfigureAwait(false); 172 | output.Clear(); 173 | first = true; 174 | output.Append($"● `{Common.MonthNames[results.First().BirthMonth]}-{results.First().BirthDay:00}`: "); 175 | } 176 | 177 | if (first) first = false; 178 | else output.Append(", "); 179 | output.Append(item); 180 | } 181 | } 182 | 183 | if (resultCount == 0) 184 | await RespondAsync( 185 | "There are no recent or upcoming birthdays (within the last 7 days and/or next 14 days).") 186 | .ConfigureAwait(false); 187 | else 188 | await doOutput(output.ToString()).ConfigureAwait(false); 189 | } 190 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Remove the line below if you want to inherit .editorconfig settings from higher directories 2 | root = true 3 | 4 | # C# files 5 | [*.cs] 6 | 7 | #### Core EditorConfig Options #### 8 | 9 | # Indentation and spacing 10 | indent_size = 4 11 | indent_style = space 12 | tab_width = 4 13 | 14 | # New line preferences 15 | end_of_line = crlf 16 | insert_final_newline = true 17 | 18 | #### .NET Coding Conventions #### 19 | 20 | # Organize usings 21 | dotnet_separate_import_directive_groups = false 22 | dotnet_sort_system_directives_first = true 23 | file_header_template = unset 24 | 25 | # this. and Me. preferences 26 | dotnet_style_qualification_for_event = false 27 | dotnet_style_qualification_for_field = false 28 | dotnet_style_qualification_for_method = false 29 | dotnet_style_qualification_for_property = false 30 | 31 | # Language keywords vs BCL types preferences 32 | dotnet_style_predefined_type_for_locals_parameters_members = true 33 | dotnet_style_predefined_type_for_member_access = true 34 | 35 | # Parentheses preferences 36 | dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity 37 | dotnet_style_parentheses_in_other_binary_operators = always_for_clarity 38 | dotnet_style_parentheses_in_other_operators = never_if_unnecessary 39 | dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity 40 | 41 | # Modifier preferences 42 | dotnet_style_require_accessibility_modifiers = for_non_interface_members 43 | 44 | # Expression-level preferences 45 | dotnet_style_coalesce_expression = true 46 | dotnet_style_collection_initializer = true 47 | dotnet_style_explicit_tuple_names = true 48 | dotnet_style_namespace_match_folder = true 49 | dotnet_style_null_propagation = true 50 | dotnet_style_object_initializer = true 51 | dotnet_style_operator_placement_when_wrapping = beginning_of_line 52 | dotnet_style_prefer_auto_properties = true:suggestion 53 | dotnet_style_prefer_compound_assignment = true 54 | dotnet_style_prefer_conditional_expression_over_assignment = true 55 | dotnet_style_prefer_conditional_expression_over_return = true 56 | dotnet_style_prefer_inferred_anonymous_type_member_names = true 57 | dotnet_style_prefer_inferred_tuple_names = true 58 | dotnet_style_prefer_is_null_check_over_reference_equality_method = true 59 | dotnet_style_prefer_simplified_boolean_expressions = true 60 | dotnet_style_prefer_simplified_interpolation = true 61 | 62 | # Field preferences 63 | dotnet_style_readonly_field = true 64 | 65 | # Parameter preferences 66 | dotnet_code_quality_unused_parameters = true 67 | 68 | # Suppression preferences 69 | dotnet_remove_unnecessary_suppression_exclusions = none 70 | 71 | # New line preferences 72 | dotnet_style_allow_multiple_blank_lines_experimental = false 73 | dotnet_style_allow_statement_immediately_after_block_experimental = true 74 | 75 | #### C# Coding Conventions #### 76 | 77 | # var preferences 78 | csharp_style_var_elsewhere = false:silent 79 | csharp_style_var_for_built_in_types = true:suggestion 80 | csharp_style_var_when_type_is_apparent = true:suggestion 81 | 82 | # Expression-bodied members 83 | csharp_style_expression_bodied_accessors = true:silent 84 | csharp_style_expression_bodied_constructors = true:silent 85 | csharp_style_expression_bodied_indexers = true:silent 86 | csharp_style_expression_bodied_lambdas = true:silent 87 | csharp_style_expression_bodied_local_functions = true:silent 88 | csharp_style_expression_bodied_methods = true:silent 89 | csharp_style_expression_bodied_operators = true:silent 90 | csharp_style_expression_bodied_properties = true:silent 91 | 92 | # Pattern matching preferences 93 | csharp_style_pattern_matching_over_as_with_null_check = true:suggestion 94 | csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion 95 | csharp_style_prefer_not_pattern = true:suggestion 96 | csharp_style_prefer_pattern_matching = true:suggestion 97 | csharp_style_prefer_switch_expression = true:suggestion 98 | 99 | # Null-checking preferences 100 | csharp_style_conditional_delegate_call = true:suggestion 101 | 102 | # Modifier preferences 103 | csharp_prefer_static_local_function = true:suggestion 104 | 105 | # Code-block preferences 106 | csharp_prefer_braces = when_multiline:silent 107 | csharp_prefer_simple_using_statement = true:suggestion 108 | csharp_style_namespace_declarations = file_scoped:suggestion 109 | 110 | # Expression-level preferences 111 | csharp_prefer_simple_default_expression = true:suggestion 112 | csharp_style_deconstructed_variable_declaration = true:suggestion 113 | csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion 114 | csharp_style_inlined_variable_declaration = true:suggestion 115 | csharp_style_pattern_local_over_anonymous_function = true 116 | csharp_style_prefer_index_operator = true:suggestion 117 | csharp_style_prefer_null_check_over_type_check = true:suggestion 118 | csharp_style_prefer_range_operator = true:suggestion 119 | csharp_style_throw_expression = true:suggestion 120 | csharp_style_unused_value_assignment_preference = discard_variable:suggestion 121 | csharp_style_unused_value_expression_statement_preference = discard_variable:silent 122 | 123 | # 'using' directive preferences 124 | csharp_using_directive_placement = outside_namespace:silent 125 | 126 | # New line preferences 127 | csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = false:suggestion 128 | csharp_style_allow_blank_lines_between_consecutive_braces_experimental = false:suggestion 129 | csharp_style_allow_embedded_statements_on_same_line_experimental = true:silent 130 | 131 | #### C# Formatting Rules #### 132 | 133 | # New line preferences 134 | csharp_new_line_before_catch = false 135 | csharp_new_line_before_else = false 136 | csharp_new_line_before_finally = false 137 | csharp_new_line_before_members_in_anonymous_types = false 138 | csharp_new_line_before_members_in_object_initializers = false 139 | csharp_new_line_before_open_brace = none 140 | csharp_new_line_between_query_expression_clauses = false 141 | 142 | # Indentation preferences 143 | csharp_indent_block_contents = true 144 | csharp_indent_braces = false 145 | csharp_indent_case_contents = true 146 | csharp_indent_case_contents_when_block = true 147 | csharp_indent_labels = flush_left 148 | csharp_indent_switch_labels = true 149 | 150 | # Space preferences 151 | csharp_space_after_cast = false 152 | csharp_space_after_colon_in_inheritance_clause = true 153 | csharp_space_after_comma = true 154 | csharp_space_after_dot = false 155 | csharp_space_after_keywords_in_control_flow_statements = true 156 | csharp_space_after_semicolon_in_for_statement = true 157 | csharp_space_around_binary_operators = before_and_after 158 | csharp_space_around_declaration_statements = false 159 | csharp_space_before_colon_in_inheritance_clause = true 160 | csharp_space_before_comma = false 161 | csharp_space_before_dot = false 162 | csharp_space_before_open_square_brackets = false 163 | csharp_space_before_semicolon_in_for_statement = false 164 | csharp_space_between_empty_square_brackets = false 165 | csharp_space_between_method_call_empty_parameter_list_parentheses = false 166 | csharp_space_between_method_call_name_and_opening_parenthesis = false 167 | csharp_space_between_method_call_parameter_list_parentheses = false 168 | csharp_space_between_method_declaration_empty_parameter_list_parentheses = false 169 | csharp_space_between_method_declaration_name_and_open_parenthesis = false 170 | csharp_space_between_method_declaration_parameter_list_parentheses = false 171 | csharp_space_between_parentheses = false 172 | csharp_space_between_square_brackets = false 173 | 174 | # Wrapping preferences 175 | csharp_preserve_single_line_blocks = true 176 | csharp_preserve_single_line_statements = true 177 | 178 | #### Naming styles #### 179 | 180 | # Naming rules 181 | 182 | dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion 183 | dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface 184 | dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i 185 | 186 | dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion 187 | dotnet_naming_rule.types_should_be_pascal_case.symbols = types 188 | dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case 189 | 190 | dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion 191 | dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members 192 | dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case 193 | 194 | # Symbol specifications 195 | 196 | dotnet_naming_symbols.interface.applicable_kinds = interface 197 | dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected 198 | dotnet_naming_symbols.interface.required_modifiers = 199 | 200 | dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum 201 | dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected 202 | dotnet_naming_symbols.types.required_modifiers = 203 | 204 | dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method 205 | dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected 206 | dotnet_naming_symbols.non_field_members.required_modifiers = 207 | 208 | # Naming styles 209 | 210 | dotnet_naming_style.pascal_case.required_prefix = 211 | dotnet_naming_style.pascal_case.required_suffix = 212 | dotnet_naming_style.pascal_case.word_separator = 213 | dotnet_naming_style.pascal_case.capitalization = pascal_case 214 | 215 | dotnet_naming_style.begins_with_i.required_prefix = I 216 | dotnet_naming_style.begins_with_i.required_suffix = 217 | dotnet_naming_style.begins_with_i.word_separator = 218 | dotnet_naming_style.begins_with_i.capitalization = pascal_case 219 | csharp_style_prefer_local_over_anonymous_function = true:suggestion 220 | csharp_style_prefer_tuple_swap = true:suggestion 221 | csharp_style_prefer_extended_property_pattern = true:suggestion 222 | 223 | csharp_style_prefer_primary_constructors = true:suggestion 224 | -------------------------------------------------------------------------------- /ApplicationCommands/ConfigModule.cs: -------------------------------------------------------------------------------- 1 | using BirthdayBot.BackgroundServices; 2 | using BirthdayBot.Data; 3 | using Discord.Interactions; 4 | using System.Text; 5 | 6 | namespace BirthdayBot.ApplicationCommands; 7 | [Group("config", HelpCmdConfig)] 8 | [DefaultMemberPermissions(GuildPermission.ManageGuild)] 9 | [CommandContextType(InteractionContextType.Guild)] 10 | public class ConfigModule : BotModuleBase { 11 | public const string HelpCmdConfig = "Configure basic settings for the bot."; 12 | public const string HelpCmdAnnounce = "Settings regarding birthday announcements."; 13 | public const string HelpCmdBirthdayRole = "Set the role given to users having a birthday."; 14 | public const string HelpCmdCheck = "Test the bot's current configuration and show the results."; 15 | 16 | const string HelpPofxBlankUnset = " Leave blank to unset."; 17 | const string HelpOptChannel = "The corresponding channel to use."; 18 | const string HelpOptRole = "The corresponding role to use."; 19 | 20 | [Group("announce", HelpCmdAnnounce)] 21 | public class SubCmdsConfigAnnounce : BotModuleBase { 22 | private const string HelpSubCmdChannel = "Set which channel will receive announcement messages."; 23 | private const string HelpSubCmdMessage = "Modify the announcement message."; 24 | private const string HelpSubCmdPing = "Set whether to ping users mentioned in the announcement."; 25 | private const string HelpSubCmdTest = "Immediately attempt to send an announcement message as configured."; 26 | 27 | internal const string ModalCidAnnounce = "edit-announce"; 28 | private const string ModalComCidSingle = "msg-single"; 29 | private const string ModalComCidMulti = "msg-multi"; 30 | 31 | [SlashCommand("help", "Show information regarding announcement messages.")] 32 | public async Task CmdAnnounceHelp() { 33 | const string subcommands = 34 | $"`/config announce` - {HelpCmdAnnounce}\n" + 35 | $" ⤷`set-channel` - {HelpSubCmdChannel}\n" + 36 | $" ⤷`set-message` - {HelpSubCmdMessage}\n" + 37 | $" ⤷`set-ping` - {HelpSubCmdPing}\n" + 38 | $" ⤷`test` - {HelpSubCmdTest}"; 39 | const string whatIs = 40 | "As the name implies, an announcement message is the messages displayed when somebody's birthday be" + 41 | "arrives. If enabled, an announcment message is shown at midnight respective to the appropriate time zone, " + 42 | "first using the user's local time (if it is known), or else using the server's default time zone, or else " + 43 | "referring back to midnight in Universal Time (UTC).\n\n" + 44 | "To enable announcement messages, use the `set-channel` subcommand."; 45 | const string editMsg = 46 | "The `set-message` subcommand allow moderators to edit the message sent into the announcement channel.\n" + 47 | "Two messages may be provided: `single` sets the message that is displayed when one user has a birthday, and " + 48 | "`multi` sets the message used when two or more users have birthdays. If only one of the two messages " + 49 | "have been set, this bot will use the same message in both cases.\n\n" + 50 | "You may use the token `%n` in your message to specify where the name(s) should appear, otherwise the names " + 51 | "will appear at the very end of your custom message."; 52 | await RespondAsync(embed: new EmbedBuilder() 53 | .WithAuthor("Announcement configuration") 54 | .WithDescription(subcommands) 55 | .AddField("What is an announcement message?", whatIs) 56 | .AddField("Customization", editMsg) 57 | .Build()).ConfigureAwait(false); 58 | } 59 | 60 | [SlashCommand("set-channel", HelpSubCmdChannel + HelpPofxBlankUnset)] 61 | public async Task CmdSetChannel([Summary(description: HelpOptChannel)] SocketTextChannel? channel = null) { 62 | await DoDatabaseUpdate(Context, s => s.AnnouncementChannel = channel?.Id); 63 | await RespondAsync(":white_check_mark: The announcement channel has been " + 64 | (channel == null ? "unset." : $"set to **{channel.Name}**.")); 65 | } 66 | 67 | [SlashCommand("set-message", HelpSubCmdMessage)] 68 | public async Task CmdSetMessage() { 69 | using var db = new BotDatabaseContext(); 70 | var settings = Context.Guild.GetConfigOrNew(db); 71 | 72 | var txtSingle = new TextInputBuilder() { 73 | Label = "Single - Message for one birthday", 74 | CustomId = ModalComCidSingle, 75 | Style = TextInputStyle.Paragraph, 76 | MaxLength = 1500, 77 | Required = false, 78 | Placeholder = BackgroundServices.BirthdayRoleUpdate.DefaultAnnounce, 79 | Value = settings.AnnounceMessage ?? "" 80 | }; 81 | var txtMulti = new TextInputBuilder() { 82 | Label = "Multi - Message for multiple birthdays", 83 | CustomId = ModalComCidMulti, 84 | Style = TextInputStyle.Paragraph, 85 | MaxLength = 1500, 86 | Required = false, 87 | Placeholder = BackgroundServices.BirthdayRoleUpdate.DefaultAnnouncePl, 88 | Value = settings.AnnounceMessagePl ?? "" 89 | }; 90 | 91 | var form = new ModalBuilder() 92 | .WithTitle("Edit announcement message") 93 | .WithCustomId(ModalCidAnnounce) 94 | .AddTextInput(txtSingle) 95 | .AddTextInput(txtMulti) 96 | .Build(); 97 | 98 | await RespondWithModalAsync(form).ConfigureAwait(false); 99 | } 100 | 101 | internal static async Task CmdSetMessageResponse(SocketModal modal, SocketGuildChannel channel, 102 | Dictionary data) { 103 | var newSingle = data[ModalComCidSingle].Value; 104 | var newMulti = data[ModalComCidMulti].Value; 105 | if (string.IsNullOrWhiteSpace(newSingle)) newSingle = null; 106 | if (string.IsNullOrWhiteSpace(newMulti)) newMulti = null; 107 | 108 | using var db = new BotDatabaseContext(); 109 | var settings = channel.Guild.GetConfigOrNew(db); 110 | if (settings.IsNew) db.GuildConfigurations.Add(settings); 111 | settings.AnnounceMessage = newSingle; 112 | settings.AnnounceMessagePl = newMulti; 113 | await db.SaveChangesAsync(); 114 | await modal.RespondAsync(":white_check_mark: Announcement messages have been updated."); 115 | } 116 | 117 | [SlashCommand("set-ping", HelpSubCmdPing)] 118 | public async Task CmdSetPing([Summary(description: "Set True to ping users, False to display them normally.")] bool option) { 119 | await DoDatabaseUpdate(Context, s => s.AnnouncePing = option); 120 | await RespondAsync($":white_check_mark: Announcement pings are now **{(option ? "on" : "off")}**.").ConfigureAwait(false); 121 | } 122 | 123 | const string HelpOptTestPlaceholder = "A user to add into the testing announcement as a placeholder."; 124 | [SlashCommand("test", HelpSubCmdTest)] 125 | public async Task CmdTest([Summary(description: HelpOptTestPlaceholder)] SocketGuildUser placeholder, 126 | [Summary(description: HelpOptTestPlaceholder)] SocketGuildUser? placeholder2 = null, 127 | [Summary(description: HelpOptTestPlaceholder)] SocketGuildUser? placeholder3 = null, 128 | [Summary(description: HelpOptTestPlaceholder)] SocketGuildUser? placeholder4 = null, 129 | [Summary(description: HelpOptTestPlaceholder)] SocketGuildUser? placeholder5 = null) { 130 | // Prepare config 131 | GuildConfig? settings; 132 | using (var db = new BotDatabaseContext()) { 133 | settings = Context.Guild.GetConfigOrNew(db); 134 | if (settings.IsNew || settings.AnnouncementChannel == null) { 135 | await RespondAsync(":x: Unable to send a birthday message. The announcement channel is not configured.") 136 | .ConfigureAwait(false); 137 | return; 138 | } 139 | } 140 | // Check permissions 141 | var announcech = Context.Guild.GetTextChannel(settings.AnnouncementChannel.Value); 142 | if (!Context.Guild.CurrentUser.GetPermissions(announcech).SendMessages) { 143 | await RespondAsync(":x: Unable to send a birthday message. Insufficient permissions to send to the announcement channel.") 144 | .ConfigureAwait(false); 145 | return; 146 | } 147 | 148 | // Send and confirm with user 149 | await RespondAsync($":white_check_mark: An announcement test will be sent to {announcech.Mention}.").ConfigureAwait(false); 150 | 151 | IEnumerable testingList = [placeholder, placeholder2, placeholder3, placeholder4, placeholder5]; 152 | await BirthdayRoleUpdate.AnnounceBirthdaysAsync(settings, Context.Guild, testingList.Where(i => i != null)!).ConfigureAwait(false); 153 | } 154 | } 155 | 156 | [SlashCommand("birthday-role", HelpCmdBirthdayRole)] 157 | public async Task CmdSetBRole([Summary(description: HelpOptRole)] SocketRole role) { 158 | if (role.IsEveryone || role.IsManaged) { 159 | await RespondAsync(":x: This role cannot be used for this setting.", ephemeral: true); 160 | return; 161 | } 162 | await DoDatabaseUpdate(Context, s => s.BirthdayRole = role.Id); 163 | await RespondAsync($":white_check_mark: The birthday role has been set to **{role.Name}**.").ConfigureAwait(false); 164 | } 165 | 166 | [SlashCommand("check", HelpCmdCheck)] 167 | public async Task CmdCheck() { 168 | static string DoTestFor(string label, Func test) 169 | => $"{label}: {(test() ? ":white_check_mark: Yes" : ":x: No")}"; 170 | 171 | var guild = Context.Guild; 172 | using var db = new BotDatabaseContext(); 173 | var guildconf = guild.GetConfigOrNew(db); 174 | if (!guildconf.IsNew) await db.Entry(guildconf).Collection(t => t.UserEntries).LoadAsync(); 175 | 176 | var result = new StringBuilder(); 177 | 178 | result.AppendLine($"Server ID: `{guild.Id}` | Bot shard ID: `{Shard.ShardId:00}`"); 179 | result.AppendLine($"Number of registered birthdays: `{guildconf.UserEntries?.Count ?? 0}`"); 180 | result.AppendLine($"Server time zone: `{guildconf.GuildTimeZone ?? "Not set - using UTC"}`"); 181 | result.AppendLine(); 182 | 183 | var hasMembers = Common.HasMostMembersDownloaded(guild); 184 | result.Append(DoTestFor("Bot has obtained the user list", () => hasMembers)); 185 | result.AppendLine($" - Has `{guild.DownloadedMemberCount}` of `{guild.MemberCount}` members."); 186 | int bdayCount = default; 187 | result.Append(DoTestFor("Birthday processing", delegate { 188 | if (!hasMembers) return false; 189 | if (guildconf.IsNew) return false; 190 | bdayCount = BackgroundServices.BirthdayRoleUpdate 191 | .GetGuildCurrentBirthdays(guildconf.UserEntries!, guildconf.GuildTimeZone).Count; 192 | return true; 193 | })); 194 | if (!hasMembers) result.AppendLine(" - Previous step failed."); 195 | else if (guildconf.IsNew) result.AppendLine(" - No data."); 196 | else result.AppendLine($" - `{bdayCount}` user(s) currently having a birthday."); 197 | result.AppendLine(); 198 | 199 | result.AppendLine(DoTestFor("Birthday role set with `/config birthday-role`", delegate { 200 | if (guildconf.IsNew) return false; 201 | SocketRole? role = guild.GetRole(guildconf.BirthdayRole ?? 0); 202 | return role != null; 203 | })); 204 | result.AppendLine(DoTestFor("Birthday role can be managed by bot", delegate { 205 | if (guildconf.IsNew) return false; 206 | SocketRole? role = guild.GetRole(guildconf.BirthdayRole ?? 0); 207 | if (role == null) return false; 208 | return guild.CurrentUser.GuildPermissions.ManageRoles && role.Position < guild.CurrentUser.Hierarchy; 209 | })); 210 | result.AppendLine(); 211 | 212 | SocketTextChannel? announcech = null; 213 | result.AppendLine(DoTestFor("(Optional) Announcement channel set with `/config announce set-channel`", delegate { 214 | if (guildconf.IsNew) return false; 215 | announcech = guild.GetTextChannel(guildconf.AnnouncementChannel ?? 0); 216 | return announcech != null; 217 | })); 218 | var disp = announcech == null ? "announcement channel" : $"<#{announcech.Id}>"; 219 | result.AppendLine(DoTestFor($"(Optional) Bot can send messages into {disp}", delegate { 220 | if (announcech == null) return false; 221 | return guild.CurrentUser.GetPermissions(announcech).SendMessages; 222 | })); 223 | 224 | await RespondAsync(embed: new EmbedBuilder() { 225 | Author = new EmbedAuthorBuilder() { Name = "Status and config check" }, 226 | Description = result.ToString() 227 | }.Build()).ConfigureAwait(false); 228 | } 229 | 230 | [SlashCommand("set-timezone", "Configure the time zone to use by default in the server." + HelpPofxBlankUnset)] 231 | public async Task CmdSetTimezone([Summary(description: HelpOptZone), Autocomplete] string? zone = null) { 232 | const string Response = ":white_check_mark: The server's time zone has been "; 233 | 234 | if (zone == null) { 235 | await DoDatabaseUpdate(Context, s => s.GuildTimeZone = null); 236 | await RespondAsync(Response + "unset.").ConfigureAwait(false); 237 | } else { 238 | string parsedZone; 239 | try { 240 | parsedZone = ParseTimeZone(zone); 241 | } catch (FormatException e) { 242 | await RespondAsync(e.Message).ConfigureAwait(false); 243 | return; 244 | } 245 | 246 | await DoDatabaseUpdate(Context, s => s.GuildTimeZone = parsedZone); 247 | await RespondAsync(Response + $"set to **{parsedZone}**.").ConfigureAwait(false); 248 | } 249 | } 250 | 251 | /// 252 | /// Helper method for updating arbitrary values without all the boilerplate. 253 | /// 254 | /// A delegate which modifies properties as needed. 255 | private static async Task DoDatabaseUpdate(SocketInteractionContext context, Action valueUpdater) { 256 | using var db = new BotDatabaseContext(); 257 | var settings = context.Guild.GetConfigOrNew(db); 258 | 259 | valueUpdater(settings); 260 | 261 | if (settings.IsNew) db.GuildConfigurations.Add(settings); 262 | await db.SaveChangesAsync(); 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | --------------------------------------------------------------------------------