Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ Simple Discord bot that responds to a single command (`!finaldays`) and responds
### Running the Bot for Local Testing

1. [Follow Mathias' instructions under Prequisities/Setup](https://brandewinder.com/2021/10/30/fsharp-discord-bot/)
1. Using the token from the previous step either
1. From the command line run `export DiscordConfig__BotToken=<yourtoken>`. This will make the token available to the app as long as you run the app from the same terminal session. I prefer doing it this way so I don't accidentally commit/push a token to GitHub
1. Using the token from the previous step either
1. From the command line run `export DiscordConfig__BotToken=<yourtoken>`. This will make the token available to the app as long as you run the app from the same terminal session. I prefer doing it this way so I don't accidentally commit/push a token to GitHub
1. Paste the token into the `AppSettings.Json` for `DiscordConfig.BotToken`

#### Azurite

The connection string `"AzureTableConnectionString": "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;TableEndpoint=http://host.docker.internal:10002/devstoreaccount1;"` from `AppSettings.json` only works with Azurite which you can run via docker via the following commands

1. `docker pull mcr.microsoft.com/azure-storage/azurite`
1. `docker run -p 10000:10000 -p 10001:10001 -p 10002:10002 mcr.microsoft.com/azure-storage/azurite`

If you want to persist storage across runs you'll need to mount a volume (check the [Azurite docks](https://docs.microsoft.com/en-us/azure/storage/common/storage-use-azurite?tabs=docker-hub) for details)
3 changes: 3 additions & 0 deletions src/EndwalkerBot/AppSettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,8 @@
"EndwalkerBotConfig": {
"EarlyAccessDate": "2021-12-03T09:00:00Z",
"ReleaseDate": "2021-12-07T09:00:00Z"
},
"TablesConfig" : {
"AzureTableConnectionString": "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;TableEndpoint=http://host.docker.internal:10002/devstoreaccount1;"
}
}
177 changes: 130 additions & 47 deletions src/EndwalkerBot/EndwalkerBot.fs
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,27 @@ open Microsoft.Extensions.Logging
open Microsoft.Extensions.Options
open DSharpPlus
open DSharpPlus.Entities
open Microsoft.Extensions.DependencyInjection
open Subscriptions

module Bot =

let daysUntil (target: DateTime) (now: DateTime) =
let remainingDays = target - now
remainingDays

let formatDays (interval: TimeSpan) =
$"{interval.Days} days, {interval.Hours} hours, and {interval.Minutes} minutes"

type FinalDays =
{ EarlyAccessDate: DateTime
EarlyAccessInterval: TimeSpan
EarlyAccessTimeStamp: int64
ReleaseDate: DateTime
ReleaseInterval: TimeSpan
ReleaseTimeStamp: int64
Now: DateTime }
Now: DateTime }
static member Create(earlyAccessDate, releaseDate, now) =
{ EarlyAccessDate = earlyAccessDate
EarlyAccessInterval = daysUntil earlyAccessDate now
EarlyAccessInterval = daysUntil earlyAccessDate now
EarlyAccessTimeStamp = DateTimeOffset(earlyAccessDate).ToUnixTimeSeconds()
ReleaseDate = releaseDate
ReleaseInterval = daysUntil releaseDate now
Expand All @@ -44,7 +43,7 @@ module Bot =
sprintf "In %s days we scions will fight, until the heavens fall, until our last breath."
sprintf "The final days will be upon us in %s"
|]

let rec getRandomExclusive (random: unit -> int) (exclude: int) =
let r = random()
if (r = exclude) then getRandomExclusive random exclude
Expand All @@ -56,7 +55,7 @@ module Bot =
let f = r.Next(0, l)
let s = getRandomExclusive (fun _ -> r.Next(0, l)) f
messages[f], messages[s]

/// https://discord.com/developers/docs/reference#message-formatting-formats
let getRelativeEpochTag (ts: int64) =
$"<t:{ts}:R>"
Expand All @@ -68,7 +67,7 @@ module Bot =
let buildEmbed (finalDays: FinalDays) =
let (firstMessage, secondMessage) = getMessageStrings ()
let earlyAccessMessage =
let formattedDate =
let formattedDate =
finalDays.EarlyAccessTimeStamp
|> getFullDateTimeEpochTag
let relativeDate =
Expand All @@ -80,7 +79,7 @@ module Bot =
|> firstMessage
$"{formattedDate}{Environment.NewLine}{relativeDate}{Environment.NewLine}{cuteMessage}"
let releaseMessage =
let formattedDate =
let formattedDate =
finalDays.ReleaseTimeStamp
|> getFullDateTimeEpochTag
let relativeDate =
Expand All @@ -98,63 +97,147 @@ module Bot =
.AddField("Early Access", earlyAccessMessage)
.AddField("Offical Launch", releaseMessage)
.WithTimestamp(finalDays.Now)
.Build()

let sendFinalDaysMessage (finalDays: FinalDays) (ctx: CommandContext) =
task {
do! ctx.TriggerTypingAsync()
let embed = buildEmbed finalDays
let! _ =
ctx.RespondAsync(embed)
return ()
} :> Task
let sendFromCommandContext (ctx: CommandContext) =
fun (embed: DiscordEmbed) ->
task {
do! ctx.TriggerTypingAsync()
let! message = ctx.RespondAsync(embed)
return message
}

let sendFromDiscordClient (client: DiscordClient) (channel: DiscordChannel) =
fun (embed: DiscordEmbed) ->
task {
let! message = client.SendMessageAsync(channel, embed)
return message
}

let sendFinalDaysMessage<'t> finalDays (send: DiscordEmbed -> Task<'t>) =
finalDays
|> buildEmbed
|> send

type BotOptions() =
let earlyAccessDefault = DateTime(2021, 12, 3, 9, 0, 0)
let releaseDefault = DateTime(2021, 12, 7, 9, 0, 0)
static member EndwalkerBotConfig = "EndwalkerBotConfig"

member val EarlyAccessDate = earlyAccessDefault with get, set
member val ReleaseDate = releaseDefault with get, set
member this.ToFinalDays now =
FinalDays.Create(this.EarlyAccessDate, this.ReleaseDate, now)

type EndwalkerBot(options: BotOptions) =
type EndwalkerBot(options: IOptions<BotOptions>, subscriptionService: SubscriptionService) =
inherit BaseCommandModule ()

let botOptions = options

let botOptions = options.Value

[<Command "finalDays">]
member _.FinalDays(ctx: CommandContext) =
let now = DateTime.UtcNow

let finalDays = FinalDays.Create(botOptions.EarlyAccessDate, botOptions.ReleaseDate, DateTime.UtcNow)

sendFinalDaysMessage finalDays ctx

let buildCommandsConfig (botOptions: BotOptions) =
member _.FinalDays(ctx: CommandContext) =
sendFromCommandContext ctx
|> sendFinalDaysMessage (botOptions.ToFinalDays DateTime.UtcNow)

[<Command "subscribe">]
[<RequireUserPermissions(Permissions.Administrator, false)>]
member _.Info(ctx:CommandContext) =
task {
let channel = ctx.Channel.Id

let subRequest = SubscriptionRequest.Create FinalDays channel ctx.User.Id ctx.Guild.Id
try
let! _ = subscriptionService.Subscribe subRequest
()
with ex ->
printfn $"Suscribe go boom {ex}"
do! ctx.TriggerTypingAsync()
let! _ = ctx.RespondAsync("Verified!")
return ()
} :> Task

let buildCommandsConfig (serviceProvider: IServiceProvider) =
let commandsConfig = CommandsNextConfiguration()
commandsConfig.StringPrefixes <- ["!"]
let svcs = ServiceCollection()
svcs.AddSingleton<BotOptions>(fun _ -> botOptions) |> ignore
commandsConfig.Services <- svcs.BuildServiceProvider()
commandsConfig.Services <- serviceProvider
commandsConfig

type DiscordOptions() =
static member DiscordConfig = "DiscordConfig"
member val BotToken = "" with get, set

type Tataru(logger: ILogger<Tataru>, options: IOptions<DiscordOptions>, botOptions: IOptions<Bot.BotOptions>) =
inherit BackgroundService()

do logger.LogInformation("Starting: {time}", DateTimeOffset.Now)
let discordConfig = DiscordConfiguration(Token=options.Value.BotToken, TokenType=TokenType.Bot)
do logger.LogInformation($"botOptions: EarlyAccessDate {botOptions.Value.EarlyAccessDate}")
let commandsConfig = Bot.buildCommandsConfig botOptions.Value
let discordClient = new DiscordClient(discordConfig)
type Tataru(logger: ILogger<Tataru>, discordClient: DiscordClient, botOptions: IOptions<Bot.BotOptions>, subService: SubscriptionService, serviceProvider:IServiceProvider) =
let commandsConfig = Bot.buildCommandsConfig serviceProvider
let commands = discordClient.UseCommandsNext(commandsConfig)
do commands.RegisterCommands<Bot.EndwalkerBot>()

override _.ExecuteAsync(ct: CancellationToken) =
let mutable timerTask: Task option = None
let stoppingCts = new CancellationTokenSource()
let mutable timer: PeriodicTimer = null
let amTime = TimeOnly(3, 0)
let pmTime = TimeOnly(4, 0)
let times = Seq.init 13 (fun i -> amTime.AddMinutes(47 + i))

let sendFinalDaysMessage (send: DiscordChannel -> DiscordEmbed -> Task<_>) (getChannel: uint64 -> Task<DiscordChannel>) finalDays =
task {
let! subs = subService.List()
let! _ =
subs
|> Seq.map (fun s ->
task {
let! c = getChannel s.ChannelId
let! m =
send c
|> Bot.sendFinalDaysMessage finalDays
return m
})
|> Task.WhenAll
return ()
} :> Task

let doWork (ct: CancellationToken) =
task {
do! discordClient.ConnectAsync()
while not ct.IsCancellationRequested do
do! Task.Delay(1000)
let mutable keepGoing = true
while not ct.IsCancellationRequested && keepGoing do
let! result = timer.WaitForNextTickAsync(ct)
keepGoing <- result
let now = DateTime.UtcNow
let nowTime = TimeOnly.FromDateTime now
let send = times |> Seq.exists (fun t -> t.Hour = nowTime.Hour && t.Minute = nowTime.Minute && t.Second = nowTime.Second)
do!
if keepGoing && send then
sendFinalDaysMessage (fun c e -> discordClient.SendMessageAsync(c, e)) discordClient.GetChannelAsync (botOptions.Value.ToFinalDays DateTime.UtcNow)
else
Task.CompletedTask
} :> Task

let stopWork (ct: CancellationToken) =
task {
let! _ =
match timerTask with
| None -> Task.FromResult(Task.CompletedTask)
| Some t ->
stoppingCts.Cancel()
Task.WhenAny(t, Task.Delay(Timeout.Infinite, ct))
return ()
}

interface IHostedService with
member _.StartAsync(ct: CancellationToken) =
task {
timer <- new PeriodicTimer(TimeSpan.FromSeconds(1.))
do commands.RegisterCommands<Bot.EndwalkerBot>()
do! discordClient.ConnectAsync()
timerTask <- doWork(stoppingCts.Token) |> Some
return!
timerTask
|> Option.filter(fun t -> t.IsCompleted)
|> Option.defaultValue Task.CompletedTask
}

member _.StopAsync(ct: CancellationToken) : Task =
task {
do! stopWork(ct)
do! discordClient.DisconnectAsync()
}

interface IDisposable with
member _.Dispose() =
stoppingCts.Cancel()
timer.Dispose()
41 changes: 20 additions & 21 deletions src/EndwalkerBot/EndwalkerBot.fsproj
Original file line number Diff line number Diff line change
@@ -1,21 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<Compile Include="EndwalkerBot.fs" />
<Compile Include="Program.fs" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="DSharpPlus" Version="4.1.0" />
<PackageReference Include="DSharpPlus.CommandsNext" Version="4.1.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="6.0.0" />
</ItemGroup>

</Project>
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="SubscriberService.fs" />
<Compile Include="EndwalkerBot.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.Data.Tables" Version="12.3.0" />
<PackageReference Include="DSharpPlus" Version="4.1.0" />
<PackageReference Include="DSharpPlus.CommandsNext" Version="4.1.0" />
<PackageReference Include="DSharpPlus.Rest" Version="4.1.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="6.0.0" />
</ItemGroup>
</Project>
16 changes: 14 additions & 2 deletions src/EndwalkerBot/Program.fs
Original file line number Diff line number Diff line change
@@ -1,17 +1,29 @@
namespace WarriorOfLight.EndwalkerBot

open Microsoft.Extensions.Configuration
open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Hosting

module Program =
open Bot

open Azure.Data.Tables
open Microsoft.Extensions.Options
open DSharpPlus

let createHostBuilder args =
Host.CreateDefaultBuilder(args)
.ConfigureServices(fun hostContext services ->
services.Configure<DiscordOptions>(hostContext.Configuration.GetSection(DiscordOptions.DiscordConfig)) |> ignore
services.Configure<BotOptions>(hostContext.Configuration.GetSection(BotOptions.EndwalkerBotConfig)) |> ignore
services.Configure<Db.TablesOptions>(hostContext.Configuration.GetSection(Db.TablesOptions.TablesConfig)) |> ignore
services.AddSingleton<TableServiceClient>(fun svcProvider ->
let options = svcProvider.GetService<IOptions<Db.TablesOptions>>()
TableServiceClient(options.Value.AzureTableConnectionString)) |> ignore
services.AddSingleton<Subscriptions.SubscriptionRepository>() |> ignore
services.AddSingleton<Subscriptions.SubscriptionService>() |> ignore
services.AddSingleton<DiscordClient>(fun svcProvider ->
let options = svcProvider.GetService<IOptions<DiscordOptions>>()
let config = DiscordConfiguration(Token=options.Value.BotToken, TokenType=TokenType.Bot)
new DiscordClient(config)) |> ignore
services.AddHostedService<Tataru>() |> ignore)

[<EntryPoint>]
Expand Down
Loading