├── .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 | [](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 |
--------------------------------------------------------------------------------