C# Discord bot made using NetCord for keeping a TikTok-style streak

init: streakbot

This contains a pretty feature-rich version of a streak bot in discord.

Minito c0498c7b 11fb2ded

+1213
+45
Commands/CheckStreakCommand.cs
···
··· 1 + using NetCord; 2 + using NetCord.Services; 3 + using NetCord.Services.ApplicationCommands; 4 + using StreakBot.Services; 5 + 6 + namespace StreakBot.Commands; 7 + 8 + public class CheckStreakCommand : ApplicationCommandModule<ApplicationCommandContext> 9 + { 10 + private readonly StreakService _streakService; 11 + 12 + public CheckStreakCommand(StreakService streakService) 13 + { 14 + _streakService = streakService; 15 + } 16 + 17 + [SlashCommand("streak", "Check your daily streak with another user")] 18 + public async Task<string> CheckStreak() 19 + { 20 + if (Context.Interaction.GuildId is not null) 21 + { 22 + return "This command can only be used in a dm!"; 23 + } 24 + 25 + var initiatorId = Context.User.Id; 26 + 27 + var streaks = await _streakService.CheckStreaksAsync(initiatorId); 28 + 29 + if (!streaks.Any()) 30 + { 31 + return "You have no streaks."; 32 + } 33 + 34 + var output = "You have the following streaks:"; 35 + 36 + foreach (var streak in streaks) 37 + { 38 + var targetId = streak.User1Id == initiatorId ? streak.User2Id : streak.User1Id; 39 + output += $"\n\nYour streak with <@{targetId}> started on {streak.CreatedDate:MMM-dd-yyyy}, and your current streak pet is {streak.StreakNumber} days old!"; 40 + } 41 + 42 + return output; 43 + 44 + } 45 + }
+43
Commands/EndStreakCommand.cs
···
··· 1 + using NetCord; 2 + using NetCord.Services.ApplicationCommands; 3 + using StreakBot.Services; 4 + 5 + namespace StreakBot.Commands; 6 + 7 + public class EndStreakCommand : ApplicationCommandModule<ApplicationCommandContext> 8 + { 9 + private readonly StreakService _streakService; 10 + 11 + public EndStreakCommand(StreakService streakService) 12 + { 13 + _streakService = streakService; 14 + } 15 + 16 + [SlashCommand("endstreak", "!!!WARNING PERMANENT DESTRUCTIVE ACTION!!! Ends the daily streak you have with another user.")] 17 + public async Task<string> EndStreak( 18 + [SlashCommandParameter(Name = "user", Description = "The user to end your streak with")] 19 + User user) 20 + { 21 + var initiatorId = Context.User.Id; 22 + var targetId = user.Id; 23 + 24 + if (initiatorId == targetId) 25 + { 26 + return "You can't end a streak with yourself!"; 27 + } 28 + 29 + if (user.IsBot) 30 + { 31 + return "You can't end a streak with a bot!"; 32 + } 33 + 34 + var success = await _streakService.DeleteStreakAsync(initiatorId, targetId); 35 + 36 + if (success == null) 37 + { 38 + return $"No streak found between <@{initiatorId}> and <@{targetId}>."; 39 + } 40 + 41 + return (bool)success ? $"Streak ended between <@{initiatorId}> and <@{targetId}>" : "Failed to end streak. Contact @minito for further information."; 42 + } 43 + }
+51
Commands/StartCommand.cs
···
··· 1 + using NetCord; 2 + using NetCord.Services.ApplicationCommands; 3 + using StreakBot.Data.Entities; 4 + using StreakBot.Services; 5 + 6 + namespace StreakBot.Commands; 7 + 8 + public class StartCommand : ApplicationCommandModule<ApplicationCommandContext> 9 + { 10 + private readonly StreakService _streakService; 11 + 12 + public StartCommand(StreakService streakService) 13 + { 14 + _streakService = streakService; 15 + } 16 + 17 + [SlashCommand("start", "Start a daily messaging streak with another user")] 18 + public async Task<string> Start( 19 + [SlashCommandParameter(Name = "user", Description = "The user to start a streak with")] 20 + User user) 21 + { 22 + var initiatorId = Context.User.Id; 23 + var targetId = user.Id; 24 + 25 + if (initiatorId == targetId) 26 + { 27 + return "You can't start a streak with yourself!"; 28 + } 29 + 30 + if (user.IsBot) 31 + { 32 + return "You can't start a streak with a bot!"; 33 + } 34 + 35 + Streak? success; 36 + if (Context.Interaction.GuildId is null) 37 + { 38 + // Group DM 39 + var channelId = Context.Channel.Id; 40 + success = await _streakService.CreateStreakAsync(initiatorId, targetId, 0, channelId); 41 + } 42 + else 43 + { 44 + // Server 45 + var serverId = Context.Interaction.GuildId.Value; 46 + success = await _streakService.CreateStreakAsync(initiatorId, targetId, serverId); 47 + } 48 + 49 + return success != null ? $"Streak started between <@{initiatorId}> and <@{targetId}>! Send messages daily to keep your streak alive!" : $"Streak already started between <@{initiatorId}> and <@{targetId}>!"; 50 + } 51 + }
+10
Data/Entities/Channel.cs
···
··· 1 + namespace StreakBot.Data.Entities; 2 + 3 + public class Channel 4 + { 5 + public int Id { get; set; } 6 + public ulong ServerId { get; set; } 7 + public ulong ChannelId { get; set; } 8 + public ChannelType ChannelType { get; set; } 9 + public ulong? MessageId { get; set; } 10 + }
+8
Data/Entities/ChannelType.cs
···
··· 1 + namespace StreakBot.Data.Entities; 2 + 3 + public enum ChannelType 4 + { 5 + StreakChannel, 6 + TimeChannel, 7 + DmChannel 8 + }
+16
Data/Entities/Streak.cs
···
··· 1 + namespace StreakBot.Data.Entities; 2 + 3 + public class Streak 4 + { 5 + public int Id { get; set; } 6 + public DateTime CreatedDate { get; set; } 7 + public ulong User1Id { get; set; } 8 + public ulong User2Id { get; set; } 9 + public ulong ServerId { get; set; } 10 + public ulong ChannelId { get; set; } 11 + public bool User1MessageSent { get; set; } 12 + public bool User2MessageSent { get; set; } 13 + public int StreakNumber { get; set; } 14 + public DateTime LastResetCheck { get; set; } 15 + public bool ReminderSent { get; set; } 16 + }
+36
Data/StreakDbContext.cs
···
··· 1 + using Microsoft.EntityFrameworkCore; 2 + using StreakBot.Data.Entities; 3 + 4 + namespace StreakBot.Data; 5 + 6 + public class StreakDbContext : DbContext 7 + { 8 + public StreakDbContext(DbContextOptions<StreakDbContext> options) : base(options) 9 + { 10 + } 11 + 12 + public DbSet<Streak> Streaks => Set<Streak>(); 13 + public DbSet<Channel> Channels => Set<Channel>(); 14 + 15 + protected override void OnModelCreating(ModelBuilder modelBuilder) 16 + { 17 + modelBuilder.Entity<Streak>(entity => 18 + { 19 + entity.HasKey(e => e.Id); 20 + entity.Property(e => e.CreatedDate).IsRequired(); 21 + entity.Property(e => e.User1Id).IsRequired(); 22 + entity.Property(e => e.User2Id).IsRequired(); 23 + entity.Property(e => e.ServerId).IsRequired(); 24 + entity.Property(e => e.LastResetCheck).IsRequired(); 25 + entity.Property(e => e.ReminderSent).IsRequired(); 26 + }); 27 + 28 + modelBuilder.Entity<Channel>(entity => 29 + { 30 + entity.HasKey(e => e.Id); 31 + entity.Property(e => e.ServerId).IsRequired(); 32 + entity.Property(e => e.ChannelId).IsRequired(); 33 + entity.Property(e => e.ChannelType).IsRequired(); 34 + }); 35 + } 36 + }
+19
Dockerfile
···
··· 1 + FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build 2 + WORKDIR /src 3 + 4 + COPY *.csproj . 5 + RUN dotnet restore 6 + 7 + COPY . . 8 + RUN dotnet publish -c Release -o /app --no-restore 9 + 10 + FROM mcr.microsoft.com/dotnet/aspnet:10.0 11 + WORKDIR /app 12 + 13 + COPY --from=build /app . 14 + 15 + ENV DATA_PATH=/app/data 16 + 17 + RUN mkdir -p /app/data 18 + 19 + ENTRYPOINT ["dotnet", "StreakBot.dll"]
+36
Handlers/MessageCreateHandler.cs
···
··· 1 + using Microsoft.Extensions.DependencyInjection; 2 + using NetCord.Gateway; 3 + using NetCord.Hosting.Gateway; 4 + using StreakBot.Services; 5 + 6 + namespace StreakBot.Handlers; 7 + 8 + public class MessageCreateHandler : IMessageCreateGatewayHandler 9 + { 10 + private readonly IServiceScopeFactory _scopeFactory; 11 + 12 + public MessageCreateHandler(IServiceScopeFactory scopeFactory) 13 + { 14 + _scopeFactory = scopeFactory; 15 + } 16 + 17 + public async ValueTask HandleAsync(Message message) 18 + { 19 + if (message.Author.IsBot) 20 + return; 21 + 22 + using var scope = _scopeFactory.CreateScope(); 23 + var streakService = scope.ServiceProvider.GetRequiredService<StreakService>(); 24 + 25 + if (message.GuildId is null) 26 + { 27 + // DM message - process for DM streaks 28 + await streakService.ProcessDmMessageAsync(message.Author.Id, message.ChannelId); 29 + } 30 + else 31 + { 32 + // Server message - process for server streaks 33 + await streakService.ProcessMessageAsync(message.Author.Id, message.GuildId.Value); 34 + } 35 + } 36 + }
+83
Migrations/20260204060128_InitialCreate.Designer.cs
···
··· 1 + // <auto-generated /> 2 + using System; 3 + using Microsoft.EntityFrameworkCore; 4 + using Microsoft.EntityFrameworkCore.Infrastructure; 5 + using Microsoft.EntityFrameworkCore.Migrations; 6 + using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 + using StreakBot.Data; 8 + 9 + #nullable disable 10 + 11 + namespace StreakBot.Migrations 12 + { 13 + [DbContext(typeof(StreakDbContext))] 14 + [Migration("20260204060128_InitialCreate")] 15 + partial class InitialCreate 16 + { 17 + /// <inheritdoc /> 18 + protected override void BuildTargetModel(ModelBuilder modelBuilder) 19 + { 20 + #pragma warning disable 612, 618 21 + modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); 22 + 23 + modelBuilder.Entity("StreakBot.Data.Entities.Channel", b => 24 + { 25 + b.Property<int>("Id") 26 + .ValueGeneratedOnAdd() 27 + .HasColumnType("INTEGER"); 28 + 29 + b.Property<ulong>("ChannelId") 30 + .HasColumnType("INTEGER"); 31 + 32 + b.Property<int>("ChannelType") 33 + .HasColumnType("INTEGER"); 34 + 35 + b.Property<ulong>("ServerId") 36 + .HasColumnType("INTEGER"); 37 + 38 + b.HasKey("Id"); 39 + 40 + b.ToTable("Channels"); 41 + }); 42 + 43 + modelBuilder.Entity("StreakBot.Data.Entities.Streak", b => 44 + { 45 + b.Property<int>("Id") 46 + .ValueGeneratedOnAdd() 47 + .HasColumnType("INTEGER"); 48 + 49 + b.Property<DateTime>("CreatedDate") 50 + .HasColumnType("TEXT"); 51 + 52 + b.Property<DateTime>("LastResetCheck") 53 + .HasColumnType("TEXT"); 54 + 55 + b.Property<bool>("ReminderSent") 56 + .HasColumnType("INTEGER"); 57 + 58 + b.Property<ulong>("ServerId") 59 + .HasColumnType("INTEGER"); 60 + 61 + b.Property<int>("StreakNumber") 62 + .HasColumnType("INTEGER"); 63 + 64 + b.Property<ulong>("User1Id") 65 + .HasColumnType("INTEGER"); 66 + 67 + b.Property<bool>("User1MessageSent") 68 + .HasColumnType("INTEGER"); 69 + 70 + b.Property<ulong>("User2Id") 71 + .HasColumnType("INTEGER"); 72 + 73 + b.Property<bool>("User2MessageSent") 74 + .HasColumnType("INTEGER"); 75 + 76 + b.HasKey("Id"); 77 + 78 + b.ToTable("Streaks"); 79 + }); 80 + #pragma warning restore 612, 618 81 + } 82 + } 83 + }
+61
Migrations/20260204060128_InitialCreate.cs
···
··· 1 + using System; 2 + using Microsoft.EntityFrameworkCore.Migrations; 3 + 4 + #nullable disable 5 + 6 + namespace StreakBot.Migrations 7 + { 8 + /// <inheritdoc /> 9 + public partial class InitialCreate : Migration 10 + { 11 + /// <inheritdoc /> 12 + protected override void Up(MigrationBuilder migrationBuilder) 13 + { 14 + migrationBuilder.CreateTable( 15 + name: "Channels", 16 + columns: table => new 17 + { 18 + Id = table.Column<int>(type: "INTEGER", nullable: false) 19 + .Annotation("Sqlite:Autoincrement", true), 20 + ServerId = table.Column<ulong>(type: "INTEGER", nullable: false), 21 + ChannelId = table.Column<ulong>(type: "INTEGER", nullable: false), 22 + ChannelType = table.Column<int>(type: "INTEGER", nullable: false) 23 + }, 24 + constraints: table => 25 + { 26 + table.PrimaryKey("PK_Channels", x => x.Id); 27 + }); 28 + 29 + migrationBuilder.CreateTable( 30 + name: "Streaks", 31 + columns: table => new 32 + { 33 + Id = table.Column<int>(type: "INTEGER", nullable: false) 34 + .Annotation("Sqlite:Autoincrement", true), 35 + CreatedDate = table.Column<DateTime>(type: "TEXT", nullable: false), 36 + User1Id = table.Column<ulong>(type: "INTEGER", nullable: false), 37 + User2Id = table.Column<ulong>(type: "INTEGER", nullable: false), 38 + ServerId = table.Column<ulong>(type: "INTEGER", nullable: false), 39 + User1MessageSent = table.Column<bool>(type: "INTEGER", nullable: false), 40 + User2MessageSent = table.Column<bool>(type: "INTEGER", nullable: false), 41 + StreakNumber = table.Column<int>(type: "INTEGER", nullable: false), 42 + LastResetCheck = table.Column<DateTime>(type: "TEXT", nullable: false), 43 + ReminderSent = table.Column<bool>(type: "INTEGER", nullable: false) 44 + }, 45 + constraints: table => 46 + { 47 + table.PrimaryKey("PK_Streaks", x => x.Id); 48 + }); 49 + } 50 + 51 + /// <inheritdoc /> 52 + protected override void Down(MigrationBuilder migrationBuilder) 53 + { 54 + migrationBuilder.DropTable( 55 + name: "Channels"); 56 + 57 + migrationBuilder.DropTable( 58 + name: "Streaks"); 59 + } 60 + } 61 + }
+89
Migrations/20260204060815_AddGroupDmSupport.Designer.cs
···
··· 1 + // <auto-generated /> 2 + using System; 3 + using Microsoft.EntityFrameworkCore; 4 + using Microsoft.EntityFrameworkCore.Infrastructure; 5 + using Microsoft.EntityFrameworkCore.Migrations; 6 + using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 + using StreakBot.Data; 8 + 9 + #nullable disable 10 + 11 + namespace StreakBot.Migrations 12 + { 13 + [DbContext(typeof(StreakDbContext))] 14 + [Migration("20260204060815_AddGroupDmSupport")] 15 + partial class AddGroupDmSupport 16 + { 17 + /// <inheritdoc /> 18 + protected override void BuildTargetModel(ModelBuilder modelBuilder) 19 + { 20 + #pragma warning disable 612, 618 21 + modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); 22 + 23 + modelBuilder.Entity("StreakBot.Data.Entities.Channel", b => 24 + { 25 + b.Property<int>("Id") 26 + .ValueGeneratedOnAdd() 27 + .HasColumnType("INTEGER"); 28 + 29 + b.Property<ulong>("ChannelId") 30 + .HasColumnType("INTEGER"); 31 + 32 + b.Property<int>("ChannelType") 33 + .HasColumnType("INTEGER"); 34 + 35 + b.Property<ulong?>("MessageId") 36 + .HasColumnType("INTEGER"); 37 + 38 + b.Property<ulong>("ServerId") 39 + .HasColumnType("INTEGER"); 40 + 41 + b.HasKey("Id"); 42 + 43 + b.ToTable("Channels"); 44 + }); 45 + 46 + modelBuilder.Entity("StreakBot.Data.Entities.Streak", b => 47 + { 48 + b.Property<int>("Id") 49 + .ValueGeneratedOnAdd() 50 + .HasColumnType("INTEGER"); 51 + 52 + b.Property<ulong>("ChannelId") 53 + .HasColumnType("INTEGER"); 54 + 55 + b.Property<DateTime>("CreatedDate") 56 + .HasColumnType("TEXT"); 57 + 58 + b.Property<DateTime>("LastResetCheck") 59 + .HasColumnType("TEXT"); 60 + 61 + b.Property<bool>("ReminderSent") 62 + .HasColumnType("INTEGER"); 63 + 64 + b.Property<ulong>("ServerId") 65 + .HasColumnType("INTEGER"); 66 + 67 + b.Property<int>("StreakNumber") 68 + .HasColumnType("INTEGER"); 69 + 70 + b.Property<ulong>("User1Id") 71 + .HasColumnType("INTEGER"); 72 + 73 + b.Property<bool>("User1MessageSent") 74 + .HasColumnType("INTEGER"); 75 + 76 + b.Property<ulong>("User2Id") 77 + .HasColumnType("INTEGER"); 78 + 79 + b.Property<bool>("User2MessageSent") 80 + .HasColumnType("INTEGER"); 81 + 82 + b.HasKey("Id"); 83 + 84 + b.ToTable("Streaks"); 85 + }); 86 + #pragma warning restore 612, 618 87 + } 88 + } 89 + }
+39
Migrations/20260204060815_AddGroupDmSupport.cs
···
··· 1 + using Microsoft.EntityFrameworkCore.Migrations; 2 + 3 + #nullable disable 4 + 5 + namespace StreakBot.Migrations 6 + { 7 + /// <inheritdoc /> 8 + public partial class AddGroupDmSupport : Migration 9 + { 10 + /// <inheritdoc /> 11 + protected override void Up(MigrationBuilder migrationBuilder) 12 + { 13 + migrationBuilder.AddColumn<ulong>( 14 + name: "ChannelId", 15 + table: "Streaks", 16 + type: "INTEGER", 17 + nullable: false, 18 + defaultValue: 0ul); 19 + 20 + migrationBuilder.AddColumn<ulong>( 21 + name: "MessageId", 22 + table: "Channels", 23 + type: "INTEGER", 24 + nullable: true); 25 + } 26 + 27 + /// <inheritdoc /> 28 + protected override void Down(MigrationBuilder migrationBuilder) 29 + { 30 + migrationBuilder.DropColumn( 31 + name: "ChannelId", 32 + table: "Streaks"); 33 + 34 + migrationBuilder.DropColumn( 35 + name: "MessageId", 36 + table: "Channels"); 37 + } 38 + } 39 + }
+86
Migrations/StreakDbContextModelSnapshot.cs
···
··· 1 + // <auto-generated /> 2 + using System; 3 + using Microsoft.EntityFrameworkCore; 4 + using Microsoft.EntityFrameworkCore.Infrastructure; 5 + using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 6 + using StreakBot.Data; 7 + 8 + #nullable disable 9 + 10 + namespace StreakBot.Migrations 11 + { 12 + [DbContext(typeof(StreakDbContext))] 13 + partial class StreakDbContextModelSnapshot : ModelSnapshot 14 + { 15 + protected override void BuildModel(ModelBuilder modelBuilder) 16 + { 17 + #pragma warning disable 612, 618 18 + modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); 19 + 20 + modelBuilder.Entity("StreakBot.Data.Entities.Channel", b => 21 + { 22 + b.Property<int>("Id") 23 + .ValueGeneratedOnAdd() 24 + .HasColumnType("INTEGER"); 25 + 26 + b.Property<ulong>("ChannelId") 27 + .HasColumnType("INTEGER"); 28 + 29 + b.Property<int>("ChannelType") 30 + .HasColumnType("INTEGER"); 31 + 32 + b.Property<ulong?>("MessageId") 33 + .HasColumnType("INTEGER"); 34 + 35 + b.Property<ulong>("ServerId") 36 + .HasColumnType("INTEGER"); 37 + 38 + b.HasKey("Id"); 39 + 40 + b.ToTable("Channels"); 41 + }); 42 + 43 + modelBuilder.Entity("StreakBot.Data.Entities.Streak", b => 44 + { 45 + b.Property<int>("Id") 46 + .ValueGeneratedOnAdd() 47 + .HasColumnType("INTEGER"); 48 + 49 + b.Property<ulong>("ChannelId") 50 + .HasColumnType("INTEGER"); 51 + 52 + b.Property<DateTime>("CreatedDate") 53 + .HasColumnType("TEXT"); 54 + 55 + b.Property<DateTime>("LastResetCheck") 56 + .HasColumnType("TEXT"); 57 + 58 + b.Property<bool>("ReminderSent") 59 + .HasColumnType("INTEGER"); 60 + 61 + b.Property<ulong>("ServerId") 62 + .HasColumnType("INTEGER"); 63 + 64 + b.Property<int>("StreakNumber") 65 + .HasColumnType("INTEGER"); 66 + 67 + b.Property<ulong>("User1Id") 68 + .HasColumnType("INTEGER"); 69 + 70 + b.Property<bool>("User1MessageSent") 71 + .HasColumnType("INTEGER"); 72 + 73 + b.Property<ulong>("User2Id") 74 + .HasColumnType("INTEGER"); 75 + 76 + b.Property<bool>("User2MessageSent") 77 + .HasColumnType("INTEGER"); 78 + 79 + b.HasKey("Id"); 80 + 81 + b.ToTable("Streaks"); 82 + }); 83 + #pragma warning restore 612, 618 84 + } 85 + } 86 + }
+51
Program.cs
···
··· 1 + using Microsoft.EntityFrameworkCore; 2 + using Microsoft.Extensions.DependencyInjection; 3 + using Microsoft.Extensions.Hosting; 4 + using NetCord.Gateway; 5 + using NetCord.Hosting.Gateway; 6 + using NetCord.Hosting.Services; 7 + using NetCord.Hosting.Services.ApplicationCommands; 8 + using StreakBot.Data; 9 + using StreakBot.Services; 10 + 11 + namespace StreakBot; 12 + 13 + public class Program 14 + { 15 + public static async Task Main(string[] args) 16 + { 17 + var dataPath = Environment.GetEnvironmentVariable("DATA_PATH") ?? "."; 18 + var connectionString = $"Data Source={Path.Combine(dataPath, "streaks.db")}"; 19 + 20 + var builder = Host.CreateDefaultBuilder(args) 21 + .ConfigureServices(services => 22 + { 23 + services.AddDbContext<StreakDbContext>(options => 24 + options.UseSqlite(connectionString)); 25 + services.AddScoped<ChannelService>(); 26 + services.AddScoped<StreakService>(); 27 + services.AddHostedService<StreakResetBackgroundService>(); 28 + services.AddGatewayHandlers(typeof(Program).Assembly); 29 + }) 30 + .UseDiscordGateway(options => 31 + { 32 + options.Intents = GatewayIntents.Guilds 33 + | GatewayIntents.GuildMessages 34 + | GatewayIntents.MessageContent; 35 + }) 36 + .UseApplicationCommands(); 37 + 38 + var host = builder.Build(); 39 + 40 + using (var scope = host.Services.CreateScope()) 41 + { 42 + var db = scope.ServiceProvider.GetRequiredService<StreakDbContext>(); 43 + await db.Database.MigrateAsync(); 44 + } 45 + 46 + host.AddSlashCommand("ping", "Ping!", () => "Pong!"); 47 + host.AddModules(typeof(Program).Assembly); 48 + 49 + await host.RunAsync(); 50 + } 51 + }
+7
README.md
··· 1 # StreakBot
··· 1 # StreakBot 2 + This is a Discord bot made using NetCord, for the purposes of creating a server to make streaks (a la TikTok or Duolingo). Creates voice channels that display the streak count and a timer of when the daily timer starts again. 3 + 4 + You'll need to do the necessary steps of setting up a Discord bot (Set up a bot at https://discord.com/developers/applications), grab the token that it spits out after creating the bot, and put it into the appsettings.json. 5 + 6 + Start a streak with /start <@username> 7 + End a streak with /endstreak <@username> 8 + Check your streaks with /streak
+152
Services/ChannelService.cs
···
··· 1 + using Microsoft.EntityFrameworkCore; 2 + using NetCord.Rest; 3 + using StreakBot.Data; 4 + using StreakBot.Data.Entities; 5 + using ChannelEntity = StreakBot.Data.Entities.Channel; 6 + using ChannelType = StreakBot.Data.Entities.ChannelType; 7 + 8 + namespace StreakBot.Services; 9 + 10 + public class ChannelService 11 + { 12 + private readonly StreakDbContext _context; 13 + private readonly RestClient _restClient; 14 + 15 + public ChannelService(StreakDbContext context, RestClient restClient) 16 + { 17 + _context = context; 18 + _restClient = restClient; 19 + } 20 + 21 + public async Task UpdateStreakChannelAsync(ulong serverId, int streakCount, bool endOfDay = false) 22 + { 23 + var channel = await _context.Channels 24 + .FirstOrDefaultAsync(c => c.ServerId == serverId && c.ChannelType == ChannelType.StreakChannel); 25 + 26 + var channelName = endOfDay ? $"🧊 {streakCount}" : $"🔥 {streakCount}"; 27 + 28 + if (channel == null) 29 + { 30 + var properties = new GuildChannelProperties(channelName, NetCord.ChannelType.VoiceGuildChannel); 31 + var createdChannel = await _restClient.CreateGuildChannelAsync(serverId, properties); 32 + 33 + channel = new ChannelEntity 34 + { 35 + ServerId = serverId, 36 + ChannelId = createdChannel.Id, 37 + ChannelType = ChannelType.StreakChannel 38 + }; 39 + 40 + _context.Channels.Add(channel); 41 + await _context.SaveChangesAsync(); 42 + } 43 + else 44 + { 45 + await _restClient.ModifyGuildChannelAsync(channel.ChannelId, options => options.WithName(channelName)); 46 + } 47 + } 48 + 49 + public async Task UpdateTimeChannelAsync(ulong serverId, TimeSpan resetTime) 50 + { 51 + var channel = await _context.Channels 52 + .FirstOrDefaultAsync(c => c.ServerId == serverId && c.ChannelType == ChannelType.TimeChannel); 53 + 54 + var timeRemaining = CalculateTimeRemaining(resetTime); 55 + var channelName = $"⏰ {timeRemaining:hh\\:mm}"; 56 + 57 + if (channel == null) 58 + { 59 + var properties = new GuildChannelProperties(channelName, NetCord.ChannelType.VoiceGuildChannel); 60 + var createdChannel = await _restClient.CreateGuildChannelAsync(serverId, properties); 61 + 62 + channel = new ChannelEntity 63 + { 64 + ServerId = serverId, 65 + ChannelId = createdChannel.Id, 66 + ChannelType = ChannelType.TimeChannel 67 + }; 68 + 69 + _context.Channels.Add(channel); 70 + await _context.SaveChangesAsync(); 71 + } 72 + else 73 + { 74 + await _restClient.ModifyGuildChannelAsync(channel.ChannelId, options => options.WithName(channelName)); 75 + } 76 + } 77 + 78 + private static TimeSpan CalculateTimeRemaining(TimeSpan resetTime) 79 + { 80 + var now = DateTime.UtcNow.TimeOfDay; 81 + 82 + if (now < resetTime) 83 + { 84 + return resetTime - now; 85 + } 86 + else 87 + { 88 + return TimeSpan.FromHours(24) - now + resetTime; 89 + } 90 + } 91 + 92 + public async Task<ChannelEntity?> GetChannelAsync(ulong serverId, ChannelType channelType) 93 + { 94 + return await _context.Channels 95 + .FirstOrDefaultAsync(c => c.ServerId == serverId && c.ChannelType == channelType); 96 + } 97 + 98 + public async Task<bool> DeleteChannelAsync(ulong channelId) 99 + { 100 + var test = await _restClient.DeleteChannelAsync(channelId); 101 + 102 + return true; 103 + } 104 + 105 + public async Task SendStreakReminderAsync(ulong user1Id, ulong user2Id, bool user1NeedsReminder, bool user2NeedsReminder) 106 + { 107 + if (user1NeedsReminder) 108 + { 109 + var dmChannel = await _restClient.GetDMChannelAsync(user1Id); 110 + var message = new MessageProperties() 111 + .WithContent($"Your streak with <@{user2Id}> will reset in less than an hour! Send a message in the server to keep it alive!"); 112 + await _restClient.SendMessageAsync(dmChannel.Id, message); 113 + } 114 + 115 + if (user2NeedsReminder) 116 + { 117 + var dmChannel = await _restClient.GetDMChannelAsync(user2Id); 118 + var message = new MessageProperties() 119 + .WithContent($"Your streak with <@{user1Id}> will reset in less than an hour! Send a message in the server to keep it alive!"); 120 + await _restClient.SendMessageAsync(dmChannel.Id, message); 121 + } 122 + } 123 + 124 + public async Task<ChannelEntity> CreateDmStreakMessageAsync(ulong channelId, int streakNumber, TimeSpan resetTime) 125 + { 126 + var timeRemaining = CalculateTimeRemaining(resetTime); 127 + var content = $"🔥 {streakNumber}\n⏰ {timeRemaining:hh\\:mm}"; 128 + 129 + var messageProperties = new MessageProperties().WithContent(content); 130 + var sentMessage = await _restClient.SendMessageAsync(channelId, messageProperties); 131 + 132 + var channel = new ChannelEntity 133 + { 134 + ChannelId = channelId, 135 + ChannelType = ChannelType.DmChannel, 136 + MessageId = sentMessage.Id 137 + }; 138 + 139 + _context.Channels.Add(channel); 140 + await _context.SaveChangesAsync(); 141 + 142 + return channel; 143 + } 144 + 145 + public async Task UpdateDmStreakMessageAsync(ulong channelId, ulong messageId, int streakNumber, TimeSpan resetTime) 146 + { 147 + var timeRemaining = CalculateTimeRemaining(resetTime); 148 + var content = $"🔥 {streakNumber}\n⏰ {timeRemaining:hh\\:mm}"; 149 + 150 + await _restClient.ModifyMessageAsync(channelId, messageId, message => message.WithContent(content)); 151 + } 152 + }
+143
Services/StreakResetBackgroundService.cs
···
··· 1 + using Microsoft.EntityFrameworkCore; 2 + using Microsoft.Extensions.DependencyInjection; 3 + using Microsoft.Extensions.Hosting; 4 + using Microsoft.Extensions.Logging; 5 + using StreakBot.Data; 6 + using StreakBot.Data.Entities; 7 + 8 + namespace StreakBot.Services; 9 + 10 + public class StreakResetBackgroundService : BackgroundService 11 + { 12 + private readonly IServiceScopeFactory _scopeFactory; 13 + private readonly ILogger<StreakResetBackgroundService> _logger; 14 + private readonly TimeSpan _checkInterval = TimeSpan.FromMinutes(1); 15 + 16 + public StreakResetBackgroundService( 17 + IServiceScopeFactory scopeFactory, 18 + ILogger<StreakResetBackgroundService> logger) 19 + { 20 + _scopeFactory = scopeFactory; 21 + _logger = logger; 22 + } 23 + 24 + protected override async Task ExecuteAsync(CancellationToken stoppingToken) 25 + { 26 + while (!stoppingToken.IsCancellationRequested) 27 + { 28 + try 29 + { 30 + await CheckAllStreaksAsync(); 31 + } 32 + catch (Exception ex) 33 + { 34 + _logger.LogError(ex, "Error checking streaks for reset"); 35 + } 36 + 37 + await Task.Delay(_checkInterval, stoppingToken); 38 + } 39 + } 40 + 41 + private async Task CheckAllStreaksAsync() 42 + { 43 + using var scope = _scopeFactory.CreateScope(); 44 + var context = scope.ServiceProvider.GetRequiredService<StreakDbContext>(); 45 + var channelService = scope.ServiceProvider.GetRequiredService<ChannelService>(); 46 + 47 + var streaks = await context.Streaks.ToListAsync(); 48 + var affectedStreaks = new List<Streak>(); 49 + 50 + foreach (var streak in streaks) 51 + { 52 + var result = await CheckAndResetIfNeededAsync(streak, channelService); 53 + 54 + if (result != null) 55 + { 56 + affectedStreaks.Add(result); 57 + } 58 + } 59 + 60 + if (context.ChangeTracker.HasChanges()) 61 + { 62 + var count = await context.SaveChangesAsync(); 63 + _logger.LogInformation("Reset {Count} streak(s)", count); 64 + } 65 + 66 + foreach (var streak in affectedStreaks.Where(s => s.ServerId != 0)) 67 + { 68 + await channelService.UpdateStreakChannelAsync(streak.ServerId, streak.StreakNumber, true); 69 + } 70 + 71 + await UpdateAllTimeChannelsAsync(streaks, channelService); 72 + await UpdateAllDmMessagesAsync(streaks, channelService, context); 73 + } 74 + 75 + private async Task<Streak?> CheckAndResetIfNeededAsync(Streak streak, ChannelService channelService) 76 + { 77 + var now = DateTime.UtcNow; 78 + var timeUntilReset = streak.LastResetCheck - now; 79 + 80 + // Check if we need to send a reminder (less than 1 hour until reset and not both have sent) 81 + if (timeUntilReset > TimeSpan.Zero && timeUntilReset <= TimeSpan.FromHours(1)) 82 + { 83 + if ((!streak.User1MessageSent || !streak.User2MessageSent) && !streak.ReminderSent) 84 + { 85 + await channelService.SendStreakReminderAsync( 86 + streak.User1Id, 87 + streak.User2Id, 88 + !streak.User1MessageSent, 89 + !streak.User2MessageSent); 90 + 91 + streak.ReminderSent = true; 92 + } 93 + } 94 + 95 + if (now <= streak.LastResetCheck) 96 + { 97 + return null; 98 + } 99 + 100 + if (!streak.User1MessageSent || !streak.User2MessageSent) 101 + { 102 + streak.StreakNumber = 0; 103 + } 104 + 105 + streak.User1MessageSent = false; 106 + streak.User2MessageSent = false; 107 + streak.ReminderSent = false; 108 + 109 + var daysToAdd = (int)Math.Ceiling((now - streak.LastResetCheck).TotalDays); 110 + streak.LastResetCheck = streak.LastResetCheck.AddDays(daysToAdd); 111 + 112 + return streak; 113 + } 114 + 115 + private async Task UpdateAllTimeChannelsAsync(List<Streak> streaks, ChannelService channelService) 116 + { 117 + var serverStreaks = streaks 118 + .Where(s => s.ServerId != 0) 119 + .DistinctBy(s => s.ServerId) 120 + .ToList(); 121 + 122 + foreach (var streak in serverStreaks) 123 + { 124 + await channelService.UpdateTimeChannelAsync(streak.ServerId, streak.CreatedDate.TimeOfDay); 125 + } 126 + } 127 + 128 + private async Task UpdateAllDmMessagesAsync(List<Streak> streaks, ChannelService channelService, StreakDbContext context) 129 + { 130 + var dmStreaks = streaks.Where(s => s.ServerId == 0).ToList(); 131 + 132 + foreach (var streak in dmStreaks) 133 + { 134 + var channel = await context.Channels 135 + .FirstOrDefaultAsync(c => c.ChannelId == streak.ChannelId && c.ChannelType == ChannelType.DmChannel); 136 + 137 + if (channel?.MessageId != null) 138 + { 139 + await channelService.UpdateDmStreakMessageAsync(channel.ChannelId, channel.MessageId.Value, streak.StreakNumber, streak.CreatedDate.TimeOfDay); 140 + } 141 + } 142 + } 143 + }
+176
Services/StreakService.cs
···
··· 1 + using Microsoft.EntityFrameworkCore; 2 + using NetCord.Services; 3 + using StreakBot.Data; 4 + using StreakBot.Data.Entities; 5 + 6 + namespace StreakBot.Services; 7 + 8 + public class StreakService 9 + { 10 + private readonly StreakDbContext _context; 11 + private readonly ChannelService _channelService; 12 + 13 + public StreakService(StreakDbContext context, ChannelService channelService) 14 + { 15 + _context = context; 16 + _channelService = channelService; 17 + } 18 + 19 + public async Task ProcessMessageAsync(ulong userId, ulong serverId) 20 + { 21 + //Streaks are per server 22 + var streak = await _context.Streaks 23 + .Where(s => s.ServerId == serverId && 24 + (s.User1Id == userId || s.User2Id == userId)) 25 + .FirstOrDefaultAsync(); 26 + 27 + if (streak == null) 28 + { 29 + return; 30 + } 31 + 32 + ProcessStreakMessage(streak, userId); 33 + 34 + if (_context.ChangeTracker.HasChanges()) 35 + { 36 + await _context.SaveChangesAsync(); 37 + 38 + await _channelService.UpdateStreakChannelAsync(serverId, streak.StreakNumber); 39 + } 40 + } 41 + 42 + public async Task ProcessDmMessageAsync(ulong userId, ulong channelId) 43 + { 44 + var streaks = await _context.Streaks 45 + .Where(s => s.ServerId == 0 && 46 + s.ChannelId == channelId && 47 + (s.User1Id == userId || s.User2Id == userId)) 48 + .ToListAsync(); 49 + 50 + foreach (var streak in streaks) 51 + { 52 + ProcessStreakMessage(streak, userId); 53 + } 54 + 55 + if (_context.ChangeTracker.HasChanges()) 56 + { 57 + await _context.SaveChangesAsync(); 58 + 59 + // Update DM message for each affected streak 60 + foreach (var streak in streaks) 61 + { 62 + var channel = await _context.Channels 63 + .FirstOrDefaultAsync(c => c.ChannelId == channelId && c.ChannelType == Data.Entities.ChannelType.DmChannel); 64 + 65 + if (channel?.MessageId != null) 66 + { 67 + await _channelService.UpdateDmStreakMessageAsync(channelId, channel.MessageId.Value, streak.StreakNumber, streak.CreatedDate.TimeOfDay); 68 + } 69 + } 70 + } 71 + } 72 + 73 + private static void ProcessStreakMessage(Streak streak, ulong userId) 74 + { 75 + bool wasUser1 = streak.User1Id == userId; 76 + bool wasUser2 = streak.User2Id == userId; 77 + 78 + bool previousUser1Sent = streak.User1MessageSent; 79 + bool previousUser2Sent = streak.User2MessageSent; 80 + 81 + if (wasUser1 && !streak.User1MessageSent) 82 + { 83 + streak.User1MessageSent = true; 84 + } 85 + else if (wasUser2 && !streak.User2MessageSent) 86 + { 87 + streak.User2MessageSent = true; 88 + } 89 + 90 + if (streak.User1MessageSent && streak.User2MessageSent && 91 + !(previousUser1Sent && previousUser2Sent)) 92 + { 93 + streak.StreakNumber++; 94 + } 95 + } 96 + 97 + public async Task<Streak?> CreateStreakAsync(ulong user1Id, ulong user2Id, ulong serverId, ulong channelId = 0) 98 + { 99 + var streaks = serverId == 0 100 + ? await _context.Streaks 101 + .Where(s => s.ChannelId == channelId && 102 + (s.User1Id == user1Id || s.User2Id == user1Id)) 103 + .ToListAsync() 104 + : await _context.Streaks 105 + .Where(s => s.ServerId == serverId && 106 + (s.User1Id == user1Id || s.User2Id == user1Id)) 107 + .ToListAsync(); 108 + 109 + if (streaks.Any(c => 110 + (c.User1Id == user1Id && c.User2Id == user2Id) || (c.User1Id == user2Id && c.User2Id == user1Id))) 111 + { 112 + return null; 113 + } 114 + 115 + var now = DateTime.UtcNow; 116 + var streak = new Streak 117 + { 118 + CreatedDate = now, 119 + User1Id = user1Id, 120 + User2Id = user2Id, 121 + ServerId = serverId, 122 + ChannelId = channelId, 123 + User1MessageSent = false, 124 + User2MessageSent = false, 125 + StreakNumber = 0, 126 + LastResetCheck = now.AddDays(1) 127 + }; 128 + 129 + _context.Streaks.Add(streak); 130 + await _context.SaveChangesAsync(); 131 + 132 + if (serverId == 0) 133 + { 134 + await _channelService.CreateDmStreakMessageAsync(channelId, streak.StreakNumber, streak.CreatedDate.TimeOfDay); 135 + } 136 + else 137 + { 138 + await _channelService.UpdateStreakChannelAsync(serverId, streak.StreakNumber); 139 + await _channelService.UpdateTimeChannelAsync(serverId, streak.CreatedDate.TimeOfDay); 140 + } 141 + 142 + return streak; 143 + } 144 + 145 + public async Task<List<Streak>> CheckStreaksAsync(ulong user1Id) 146 + { 147 + var streaks = await _context.Streaks 148 + .Where(s => s.User1Id == user1Id || s.User2Id == user1Id) 149 + .ToListAsync(); 150 + 151 + return streaks; 152 + } 153 + 154 + public async Task<bool?> DeleteStreakAsync(ulong user1Id, ulong user2Id) 155 + { 156 + var streak = await _context.Streaks 157 + .Where(s => (s.User1Id == user1Id && s.User2Id == user2Id) || (s.User1Id == user2Id && s.User2Id == user1Id)) 158 + .FirstOrDefaultAsync(); 159 + 160 + if (streak == null) 161 + { 162 + return null; 163 + } 164 + 165 + var channels = await _context.Channels 166 + .Where(c => c.ServerId == streak.ServerId) 167 + .ToListAsync(); 168 + 169 + foreach (var channel in channels) 170 + { 171 + await _channelService.DeleteChannelAsync(channel.ChannelId); 172 + } 173 + 174 + return true; 175 + } 176 + }
+26
StreakBot.csproj
···
··· 1 + <Project Sdk="Microsoft.NET.Sdk"> 2 + 3 + <PropertyGroup> 4 + <OutputType>Exe</OutputType> 5 + <TargetFramework>net10.0</TargetFramework> 6 + <ImplicitUsings>enable</ImplicitUsings> 7 + <Nullable>enable</Nullable> 8 + </PropertyGroup> 9 + 10 + <ItemGroup> 11 + <Content Include="appsettings.json"> 12 + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> 13 + </Content> 14 + </ItemGroup> 15 + 16 + <ItemGroup> 17 + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0"> 18 + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> 19 + <PrivateAssets>all</PrivateAssets> 20 + </PackageReference> 21 + <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.0" /> 22 + <PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" /> 23 + <PackageReference Include="NetCord.Hosting.Services" Version="1.0.0-alpha.460" /> 24 + </ItemGroup> 25 + 26 + </Project>
+25
StreakBot.sln
···
··· 1 +  2 + Microsoft Visual Studio Solution File, Format Version 12.00 3 + # Visual Studio Version 17 4 + VisualStudioVersion = 17.14.36915.13 d17.14 5 + MinimumVisualStudioVersion = 10.0.40219.1 6 + Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StreakBot", "StreakBot.csproj", "{DAD8364F-8254-4792-3562-CB128F9F2EA0}" 7 + EndProject 8 + Global 9 + GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 + Debug|Any CPU = Debug|Any CPU 11 + Release|Any CPU = Release|Any CPU 12 + EndGlobalSection 13 + GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 + {DAD8364F-8254-4792-3562-CB128F9F2EA0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 + {DAD8364F-8254-4792-3562-CB128F9F2EA0}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 + {DAD8364F-8254-4792-3562-CB128F9F2EA0}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 + {DAD8364F-8254-4792-3562-CB128F9F2EA0}.Release|Any CPU.Build.0 = Release|Any CPU 18 + EndGlobalSection 19 + GlobalSection(SolutionProperties) = preSolution 20 + HideSolutionNode = FALSE 21 + EndGlobalSection 22 + GlobalSection(ExtensibilityGlobals) = postSolution 23 + SolutionGuid = {0E360FA5-FFCA-426F-B484-2FFAD718B33C} 24 + EndGlobalSection 25 + EndGlobal
+11
appsettings.json
···
··· 1 + { 2 + "Discord": { 3 + "Token": "" 4 + }, 5 + "Logging": { 6 + "LogLevel": { 7 + "Default": "Information", 8 + "Microsoft.Hosting.Lifetime": "Information" 9 + } 10 + } 11 + }