Discord.Net.Scheduler 1.0.0

dotnet add package Discord.Net.Scheduler --version 1.0.0
                    
NuGet\Install-Package Discord.Net.Scheduler -Version 1.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Discord.Net.Scheduler" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Discord.Net.Scheduler" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="Discord.Net.Scheduler" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Discord.Net.Scheduler --version 1.0.0
                    
#r "nuget: Discord.Net.Scheduler, 1.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Discord.Net.Scheduler@1.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Discord.Net.Scheduler&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=Discord.Net.Scheduler&version=1.0.0
                    
Install as a Cake Tool

Discord.Net.Scheduler

Cron jobs, delayed messages, and scheduled tasks for Discord.NET bots. Because scheduling should be easy, not a project in itself.

dotnet add package Discord.Net.Scheduler

Or for persistent storage:

dotnet add package Discord.Net.Scheduler.Redis
dotnet add package Discord.Net.Scheduler.EntityFrameworkCore

Why this exists

Every Discord.NET bot eventually needs to send a message at a specific time — be it a daily announcement, a reminder 30 minutes after someone joins a voice channel, or a weekly digest. Most people just throw in a Timer or add Quartz.NET and fight with it for a day. This library is the middle ground: it understands Discord.NET types natively, doesn't require you to learn a whole new job system, and still gives you the serious stuff (persistent stores, middleware, telemetry).


Quick start

Register the scheduler in your DI container:

builder.Services.AddDiscordScheduler(options =>
{
    options.PollingIntervalMs = 1000;
    options.RecoverJobsOnStart = true;
});

Get the scheduler and start scheduling:

var scheduler = host.Services.GetRequiredService<JobScheduler>();

// Send a message in 30 seconds
await scheduler.ScheduleAsync(job => job
    .SendMessage(channelId, "⏰ This fires in 30 seconds!")
    .In(TimeSpan.FromSeconds(30))
    .WithName("welcome-message"));

// Daily at 8 AM, timezone-aware
await scheduler.ScheduleRecurringAsync(job => job
    .SendMessage(channelId, "🌅 Good morning!")
    .WithCron("0 8 * * *")
    .WithTimezone(TimeZoneInfo.FindSystemTimeZoneById("FLE Standard Time")));

That's it. The scheduler runs as a BackgroundService, checks for due jobs every second (configurable), handles retries, and logs everything.


What you can schedule

Send a text message

await scheduler.ScheduleAsync(job => job
    .SendMessage(channelId, "Hello from the past!")
    .At(new DateTimeOffset(2026, 12, 25, 10, 0, 0, TimeSpan.Zero)));

Send an embed (with optional text)

var embed = new EmbedBuilder()
    .WithTitle("Scheduled Announcement")
    .WithDescription("This was planned in advance!")
    .WithColor(Color.Purple)
    .Build();

await scheduler.ScheduleAsync(job => job
    .SendEmbed(channelId, embed, "Check this out!")
    .WithDelay(TimeSpan.FromHours(2)));

Edit an existing message

await scheduler.ScheduleAsync(job => job
    .EditMessage(channelId, messageId, "Updated content!")
    .At(new DateTimeOffset(2026, 6, 15, 12, 0, 0, TimeSpan.Zero)));

Run arbitrary code

When you need something custom:

await scheduler.ScheduleAsync(job => job
    .Execute(async (ctx, ct) =>
    {
        var logger = ctx.GetService<ILogger<Program>>();
        logger.LogInformation("Running maintenance at {Time}", DateTimeOffset.UtcNow);

        var guild = ctx.Client.GetGuild(guildId);
        var channels = guild.Channels;

        return JobResult.Success(TimeSpan.Zero);
    })
    .WithCron("0 3 * * *")
    .WithName("maintenance-task"));

The ctx gives you access to the Discord client and DI container — ctx.GetService<T>() and ctx.GetOptionalService<T>().


Recurring jobs

RecurringJobBuilder has helpers so you don't always have to type cron:

await scheduler.ScheduleRecurringAsync(job => job
    .SendMessage(channelId, "it's midnight")
    .Daily());

await scheduler.ScheduleRecurringAsync(job => job
    .SendMessage(channelId, "top of the hour")
    .Hourly());

await scheduler.ScheduleRecurringAsync(job => job
    .SendMessage(channelId, "sunday morning")
    .Weekly());

await scheduler.ScheduleRecurringAsync(job => job
    .SendMessage(channelId, "new month!")
    .Monthly());

Or write your own cron:

.WithCron("*/15 * * * *")  // every 15 minutes
.WithCron("0 9-17 * * 1-5")  // 9 AM to 5 PM, weekdays
.WithCron("@hourly")  // named shortcuts work too

Job API

// Schedule a one-time job
ScheduleAsync(Action<ScheduledJobBuilder> build)

// Schedule a recurring job
ScheduleRecurringAsync(Action<RecurringJobBuilder> build)

// Cancel a job
CancelAsync(string jobId)

// Move a job to a different time
RescheduleAsync(string jobId, DateTimeOffset newTime)

// Query jobs
GetJobAsync(string jobId)
GetPendingJobsAsync()
GetAllJobsAsync()
GetJobCountAsync()

Every builder method returns the builder, so you chain as much or as little as you want:

.WithCron("0 8 * * *")
.WithTimezone(tz)
.WithRetries(5, TimeSpan.FromSeconds(30))
.ExpiresAt(DateTimeOffset.UtcNow.AddDays(7))
.WithMetadata("author", "ozotov");

Cron expressions

Standard 5-field cron. No 6th field for seconds, no year field — keeps it predictable.

Shortcut Expands to
@yearly / @annually 0 0 1 1 *
@monthly 0 0 1 * *
@weekly 0 0 * * 0
@daily / @midnight 0 0 * * *
@hourly 0 * * * *
Field Range Specials
Minute 0-59 * , - /
Hour 0-23 * , - /
Day of Month 1-31 * , - /
Month 1-12 * , - /
Day of Week 0-7 (0 and 7 = Sunday) * , - /

Step values work: */15 for every 15 minutes, 0-30/5 for every 5 minutes in the first half-hour. Named months/days are not supported — just use numbers.


Persistent storage

By default jobs live in memory and die when your bot restarts. For production you'll want something that survives a crash.

Redis

services.AddDiscordScheduler();
services.AddRedisJobStore("localhost:6379");

You can pass a ConnectionMultiplexer if you already have one:

services.AddRedisJobStore(multiplexer, keyPrefix: "mybot:jobs:");

EF Core

Works with any provider — SQLite, PostgreSQL, SQL Server:

services.AddDiscordScheduler();
services.AddEfCoreJobStore<SchedulerDbContext>(options =>
    options.UseSqlite("Data Source=jobs.db"));

Your context inherits from SchedulerDbContext:

public class MyDbContext : SchedulerDbContext
{
    public MyDbContext(DbContextOptions options) : base(options) { }
}

Write your own

Implement IJobStore:

public class MyJobStore : IJobStore
{
    public Task AddAsync(IScheduledJob job, CancellationToken ct) { /* ... */ }
    // ... IJobStore has AddAsync, RemoveAsync, GetAsync, GetPendingAsync,
    //     GetAllAsync, UpdateAsync, MarkCompletedAsync, MarkFailedAsync,
    //     CountAsync, ClearAsync
}

services.AddJobStore<MyJobStore>();

Middleware

Jobs pass through a pipeline before execution. Middleware can log, measure, rate-limit, or short-circuit execution:

services.AddDiscordScheduler();
services.AddJobMiddleware<LoggingMiddleware>();
services.AddJobMiddleware<ErrorHandlingMiddleware>();

Or add middleware inline:

var pipeline = host.Services.GetRequiredService<JobExecutionPipeline>();
pipeline.Use(async (ctx, next) =>
{
    ctx.Metadata["started"] = DateTimeOffset.UtcNow.ToString("O");
    var result = await next(ctx);
    ctx.Metadata["finished"] = DateTimeOffset.UtcNow.ToString("O");
    return result;
});

Built-in middleware:

  • LoggingMiddleware — logs execution start, duration, result
  • ErrorHandlingMiddleware — catches exceptions, marks job failed
  • You can write your own by implementing IJobMiddleware

Source generators (AOT)

If you're targeting native AOT or just hate reflection, annotate your classes with [CronJob]:

[CronJob("0 9 * * *")]
public partial class DailyDigestJob : IDiscordCronJob
{
    private readonly DiscordSocketClient _client;
    private readonly ILogger<DailyDigestJob> _logger;

    public DailyDigestJob(DiscordSocketClient client, ILogger<DailyDigestJob> logger)
    {
        _client = client;
        _logger = logger;
    }

    public async Task<JobResult> ExecuteAsync(JobContext context, CancellationToken ct)
    {
        var channel = await _client.GetChannelAsync(channelId) as IMessageChannel;
        return JobResult.Success(TimeSpan.Zero);
    }
}

The source generator picks it up at compile time and generates AddGeneratedCronJobs() — no runtime scanning, no reflection.


OpenTelemetry

Every job execution emits metrics. Enabled by default via SchedulerOptions.EnableMetrics = true.

Metric What it tracks
scheduler.job.scheduled Counter — new jobs created
scheduler.job.completed Counter — successful runs
scheduler.job.failed Counter — jobs that errored out
scheduler.job.cancelled Counter — cancelled before execution
scheduler.job.execution_time Histogram — how long jobs took (ms)
scheduler.job.active Gauge — currently executing
services.AddOpenTelemetry()
    .WithMetrics(metrics => metrics
        .AddMeter("Discord.Net.Scheduler")
        .AddConsoleExporter());

Comparison with alternatives

This Quartz.NET Hangfire Manual Timer
Discord-first API yes no no yes
Cron + timezones yes yes yes no
Persistent store Redis, EF Core many SQL no
Middleware pipeline built-in not really no no
AOT / source gen yes no no no
OpenTelemetry yes no not built-in no
Package size ~200 KB ~5 MB ~3 MB 0
Setup time 2 lines ~50 lines ~10 lines you build it

Quartz.NET is more battle-tested, but it's massive and has no idea what a Discord channel is. Hangfire is great for web apps but doesn't speak Discord either. If you just need to schedule Discord messages and you want it done today, this library is probably a better fit.


Building from source

dotnet build
dotnet test    # 43 tests, last I checked
dotnet pack    # produces nuget packages in ./artifacts/

The solution targets net8.0, net9.0, and net10.0 for the main library, and netstandard2.0 for the source generator (so it works with any SDK).


Project structure

src/
├── Discord.Net.Scheduler/              # core library
│   ├── Jobs/                           # job types + JobWrapper for serialization
│   ├── Scheduling/                     # CronParser, CronExpression, JobScheduler
│   │   └── JobStore/                   # IJobStore + InMemoryJobStore
│   ├── Pipeline/                       # JobExecutionPipeline, middleware
│   ├── Extensions/                     # DI registration
│   └── Telemetry/                      # SchedulerMetrics
├── Discord.Net.Scheduler.Redis/        # RedisJobStore
├── Discord.Net.Scheduler.EntityFrameworkCore/  # EfJobStore + SchedulerDbContext
└── Discord.Net.Scheduler.SourceGenerator/      # [CronJob] source generator

What's missing / known quirks

  • No GUI dashboard yet (planned, but I wanted the core solid first)
  • Redis serializer uses JobWrapper — it works but if you have complex custom job types you'll need to handle deserialization yourself
  • The NuGet package has a NU1902 warning for OpenTelemetry.Api 1.11.2 (moderate severity — non-critical for Discord bots, but I'll bump it)
  • XML doc comments are there for public API but some edge cases aren't documented yet — PRs welcome

License

MIT. Do what you want, just don't blame me when your bot sends a scheduled message at 3 AM to the wrong channel because you set the timezone wrong.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (3)

Showing the top 3 NuGet packages that depend on Discord.Net.Scheduler:

Package Downloads
Discord.Net.Scheduler.Redis

Redis job store for Discord.Net.Scheduler. Provides a persistent job store backed by Redis, enabling job recovery across bot restarts and horizontal scaling.

Discord.Net.Scheduler.EntityFrameworkCore

Entity Framework Core job store for Discord.Net.Scheduler. Provides a persistent job store backed by any EF Core-supported database (SQLite, PostgreSQL, SQL Server, etc.).

Discord.Net.AdminDashboard

Embedded admin dashboard for Discord.NET bots. One-line setup adds a local web dashboard at localhost:5000 with guild stats, job management, live logs, and monitoring. Powered by Minimal API + SignalR. No external dependencies.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0 198 6/17/2026