diff --git a/readme.md b/readme.md index b28b6bc..0ed87d5 100644 --- a/readme.md +++ b/readme.md @@ -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=`. 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=`. 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) diff --git a/src/EndwalkerBot/AppSettings.json b/src/EndwalkerBot/AppSettings.json index ba341ec..e440556 100644 --- a/src/EndwalkerBot/AppSettings.json +++ b/src/EndwalkerBot/AppSettings.json @@ -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;" } } \ No newline at end of file diff --git a/src/EndwalkerBot/EndwalkerBot.fs b/src/EndwalkerBot/EndwalkerBot.fs index 7e8250b..bf04a37 100644 --- a/src/EndwalkerBot/EndwalkerBot.fs +++ b/src/EndwalkerBot/EndwalkerBot.fs @@ -10,17 +10,16 @@ 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 @@ -28,10 +27,10 @@ module Bot = 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 @@ -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 @@ -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) = $"" @@ -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 = @@ -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 = @@ -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, subscriptionService: SubscriptionService) = inherit BaseCommandModule () - - let botOptions = options - + let botOptions = options.Value + [] - 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) + + [] + [] + 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(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, options: IOptions, botOptions: IOptions) = - 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, discordClient: DiscordClient, botOptions: IOptions, subService: SubscriptionService, serviceProvider:IServiceProvider) = + let commandsConfig = Bot.buildCommandsConfig serviceProvider let commands = discordClient.UseCommandsNext(commandsConfig) - do commands.RegisterCommands() - - 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) 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() + 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() diff --git a/src/EndwalkerBot/EndwalkerBot.fsproj b/src/EndwalkerBot/EndwalkerBot.fsproj index 5349f2c..3c67d6b 100644 --- a/src/EndwalkerBot/EndwalkerBot.fsproj +++ b/src/EndwalkerBot/EndwalkerBot.fsproj @@ -1,21 +1,20 @@ - - - - Exe - net6.0 - - - - - - - - - - - - - - - - + + + Exe + net6.0 + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/EndwalkerBot/Program.fs b/src/EndwalkerBot/Program.fs index 7aa9a7a..d73f8c2 100644 --- a/src/EndwalkerBot/Program.fs +++ b/src/EndwalkerBot/Program.fs @@ -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(hostContext.Configuration.GetSection(DiscordOptions.DiscordConfig)) |> ignore services.Configure(hostContext.Configuration.GetSection(BotOptions.EndwalkerBotConfig)) |> ignore + services.Configure(hostContext.Configuration.GetSection(Db.TablesOptions.TablesConfig)) |> ignore + services.AddSingleton(fun svcProvider -> + let options = svcProvider.GetService>() + TableServiceClient(options.Value.AzureTableConnectionString)) |> ignore + services.AddSingleton() |> ignore + services.AddSingleton() |> ignore + services.AddSingleton(fun svcProvider -> + let options = svcProvider.GetService>() + let config = DiscordConfiguration(Token=options.Value.BotToken, TokenType=TokenType.Bot) + new DiscordClient(config)) |> ignore services.AddHostedService() |> ignore) [] diff --git a/src/EndwalkerBot/SubscriberService.fs b/src/EndwalkerBot/SubscriberService.fs new file mode 100644 index 0000000..dc5e16b --- /dev/null +++ b/src/EndwalkerBot/SubscriberService.fs @@ -0,0 +1,119 @@ +namespace WarriorOfLight.EndwalkerBot + +[] +module AsyncSeqHelp = + open System.Collections.Generic + type IAsyncEnumerable<'T> with + member this.AsTask () = task { + let mutable moreData = true + let output = ResizeArray () + let enumerator = this.GetAsyncEnumerator() + while moreData do + let! next = enumerator.MoveNextAsync() + moreData <- next + if moreData then output.Add enumerator.Current + return output.ToArray() + } + +module Db = + open Azure.Data.Tables + + type TablesOptions() = + static member TablesConfig = "TablesConfig" + member val AzureTableConnectionString: string = null with get, set + + type TablesService(tableServiceClient: TableServiceClient, tableName: string) = + do tableServiceClient.CreateTableIfNotExists(tableName) |> ignore + let tableClient = tableServiceClient.GetTableClient(tableName) + + member _.UpsertAsync(entity) = + tableClient.AddEntityAsync(entity) + + member _.List() = + task { + let! results = tableClient.QueryAsync().AsTask() + return results + } + +module Core = + type ChannelId = uint64 + type UserId = uint64 + type GuildId = uint64 + +module Subscriptions = + + open System + open Core + open Azure.Data.Tables + + type SubscriptionType = + | FinalDays + | Other of string + + type SubscriptionRequest = + { SubscriptionType: SubscriptionType + ChannelId: ChannelId + UserId: UserId + GuildId: GuildId + RequestedOn: DateTime } + static member Create subType channelId userId guildId = + { SubscriptionType = subType + ChannelId = channelId + UserId = userId + GuildId = guildId + RequestedOn = DateTime.UtcNow } + + let toTableEntity request = + let subscription = Map.empty + let entity = TableEntity subscription + entity.PartitionKey <- + match request.SubscriptionType with + | FinalDays -> "finaldays" + | Other s -> s.ToLower() + entity.RowKey <- request.ChannelId.ToString() + entity.Add ("UserId", request.UserId :> obj) + entity.Add ("GuildId", request.GuildId :> obj) + entity.Add("RequestedOn", request.RequestedOn :> obj) + entity + + let fromTableEntity (entity: TableEntity) = + let subType = + match entity.PartitionKey with + | "finaldays" -> FinalDays + | s -> Other s + let channelId = entity.RowKey |> uint64 + let userId = + match entity.TryGetValue("UserId") with + | false, _ -> 0UL + | true, uid -> System.Convert.ToUInt64(uid) + let guildId = + match entity.TryGetValue("GuildId") with + | false, _ -> 0UL + | true, gid -> System.Convert.ToUInt64(gid) + let requestedOn = + entity.GetDateTimeOffset("RequestedOn") + |> Option.ofNullable + |> Option.map (fun dto -> dto.DateTime) + |> Option.defaultValue DateTime.UtcNow + + { SubscriptionType = subType + ChannelId = channelId + UserId = userId + GuildId = guildId + RequestedOn = requestedOn } + + type SubscriptionRepository(tableClient) = + let tablesService = Db.TablesService(tableClient, "subscriptions") + member _.UpsertAsync(subEntity) = tablesService.UpsertAsync(subEntity) + member _.List() = tablesService.List() + + type SubscriptionService(subRepo: SubscriptionRepository) = + member _.Subscribe(subRequest) = + subRequest + |> toTableEntity + |> subRepo.UpsertAsync + member _.List() = + task { + let! subs = subRepo.List() + return subs |> Seq.map fromTableEntity + }