diff --git a/FSharp.Examples.Polling/.editorconfig b/FSharp.Examples.Polling/.editorconfig new file mode 100644 index 00000000..3504404e --- /dev/null +++ b/FSharp.Examples.Polling/.editorconfig @@ -0,0 +1,33 @@ +# http://EditorConfig.org + +# This file is the top-most EditorConfig file +root = true + +# All Files +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +# Solution Files +[*.sln] +indent_style = tab + +# XML Project Files +[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] +indent_size = 2 + +# Configuration Files +[*.{json,xml,yml,config,props,targets,nuspec,resx,ruleset,vsixmanifest,vsct}] +indent_size = 2 + +[*.cs] +# RCS1090: Call 'ConfigureAwait(false)'. +dotnet_diagnostic.RCS1090.severity = none + +[*.md] +trim_trailing_whitespace = false +indent_size = 2 diff --git a/FSharp.Examples.Polling/Configuration.fs b/FSharp.Examples.Polling/Configuration.fs deleted file mode 100644 index d9abdef8..00000000 --- a/FSharp.Examples.Polling/Configuration.fs +++ /dev/null @@ -1,10 +0,0 @@ -// Configuration for Telegram Bot (Fsharp) -// -// Copyright (c) 2021 Arvind Devarajan -// Licensed to you under the MIT License. -// See the LICENSE file in the project root for more information. - -namespace FSharp.Examples.Polling - -module TelegramBotCfg = - let token = "{BOT_TOKEN}" diff --git a/FSharp.Examples.Polling/Extensions.fs b/FSharp.Examples.Polling/Extensions.fs new file mode 100644 index 00000000..1738eee8 --- /dev/null +++ b/FSharp.Examples.Polling/Extensions.fs @@ -0,0 +1,22 @@ +// Extension functions needed to extract bot configuration +// +// Copyright (c) 2023 Arvind Devarajan +// Licensed to you under the MIT License. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.Extensions.DependencyInjection + +open System +open Microsoft.Extensions.Options +open System.Runtime.CompilerServices + +[] +type PollingExtensions() = + [] + static member GetConfiguration<'T when 'T: not struct>(sp: IServiceProvider) = + let o = sp.GetService>() + + if isNull o then + raise <| ArgumentNullException nameof<'T> + + o.Value diff --git a/FSharp.Examples.Polling/FSharp.Examples.Polling.fsproj b/FSharp.Examples.Polling/FSharp.Examples.Polling.fsproj index fbaf8432..75d5381f 100644 --- a/FSharp.Examples.Polling/FSharp.Examples.Polling.fsproj +++ b/FSharp.Examples.Polling/FSharp.Examples.Polling.fsproj @@ -1,23 +1,25 @@ - - - - Exe - net6.0 - enable - 3390;$(WarnOn) - - - - - Always - - - - - - - - - - + + + net6.0 + + + + + + + + + + + + + + Always + + + + + + + diff --git a/FSharp.Examples.Polling/Program.fs b/FSharp.Examples.Polling/Program.fs index c2b76a40..3c1b52a7 100644 --- a/FSharp.Examples.Polling/Program.fs +++ b/FSharp.Examples.Polling/Program.fs @@ -1,261 +1,48 @@ -// Example Telegram bot in F# +// Bot entrypoint // -// Copyright (c) 2021 Arvind Devarajan +// Copyright (c) 2023 Arvind Devarajan // Licensed to you under the MIT License. // See the LICENSE file in the project root for more information. namespace FSharp.Examples.Polling -open System -open System.IO -open System.Threading -open Telegram.Bot -open Telegram.Bot.Types -open Telegram.Bot.Exceptions -open Telegram.Bot.Types.Enums -open Telegram.Bot.Types.InlineQueryResults -open Telegram.Bot.Types.ReplyMarkups -open Telegram.Bot.Polling -open System.Threading.Tasks - -module Handlers = - let handleErrorAsync (botClient:ITelegramBotClient) (err:Exception) (cts:CancellationToken) = async { - let errormsg = - match err with - | :? ApiRequestException as apiex -> $"Telegram API Error:\n[{apiex.ErrorCode}]\n{apiex.Message}" - | _ -> err.ToString() - - Console.WriteLine(errormsg) - } - - let botOnCallbackQueryReceived (botClient:ITelegramBotClient) (query:CallbackQuery) = async { - do! - botClient.AnswerCallbackQueryAsync(query.Id, $"Received {query.Data}") - |> Async.AwaitTask - - do! - botClient.SendTextMessageAsync(ChatId(query.Message.Chat.Id), $"Received {query.Data}") - |> Async.AwaitTask - |> Async.Ignore - } - - let botOnInlineQueryReceived (botClient:ITelegramBotClient) (inlinequery:InlineQuery) = async { - Console.WriteLine($"Received inline query from: {inlinequery.From.Id}"); - - // displayed result - let results = seq { - InlineQueryResultArticle( - id = "3", - title = "TgBots", - inputMessageContent = InputTextMessageContent("hello")) - } - - do! - botClient.AnswerInlineQueryAsync(inlinequery.Id, - results |> Seq.cast, - isPersonal = true, - cacheTime = 0) - |> Async.AwaitTask - |> Async.Ignore - } - - let botOnChosenInlineResultReceived (botClient:ITelegramBotClient) (chosenInlineResult:ChosenInlineResult) = async { - Console.WriteLine($"Received inline result: {chosenInlineResult.ResultId}") - } - - let botOnMessageReceived (botClient:ITelegramBotClient) (message:Message) = - Console.WriteLine($"Receive message type: {message.Type}"); - - let sendInlineKeyboard = async { - do! - botClient.SendChatActionAsync(ChatId(message.Chat.Id), ChatAction.Typing) - |> Async.AwaitTask - |> Async.Ignore - - let inlineKeyboard = seq { - // first row - seq { - InlineKeyboardButton.WithCallbackData("1.1", "11"); - InlineKeyboardButton.WithCallbackData("1.2", "12"); - }; - - // second row - seq { - InlineKeyboardButton.WithCallbackData("2.1", "21"); - InlineKeyboardButton.WithCallbackData("2.2", "22"); - }; - } - - do! - botClient.SendTextMessageAsync( - chatId = ChatId(message.Chat.Id), - text = "Choose", - replyMarkup = InlineKeyboardMarkup(inlineKeyboard)) - |> Async.AwaitTask - |> Async.Ignore - } +open Microsoft.Extensions.DependencyInjection +open Microsoft.Extensions.Hosting - let sendReplyKeyboard = async { - let replyKeyboardMarkup = - ReplyKeyboardMarkup( seq { - - // first row - seq { KeyboardButton("1.1"); KeyboardButton("1.2") }; - - // second row - seq { KeyboardButton("1.1"); KeyboardButton("1.2") }; - }, - ResizeKeyboard = true) - - do! - botClient.SendTextMessageAsync( - chatId = ChatId(message.Chat.Id), - text = "Choose", - replyMarkup = replyKeyboardMarkup) - |> Async.AwaitTask - |> Async.Ignore - } - - let removeKeyboard = async { - do! - botClient.SendTextMessageAsync( - chatId = ChatId(message.Chat.Id), - text = "Removing keyboard", - replyMarkup = ReplyKeyboardRemove()) - |> Async.AwaitTask - |> Async.Ignore - } - - let sendFile = async { - do! - botClient.SendChatActionAsync(ChatId(message.Chat.Id), ChatAction.UploadPhoto) - |> Async.AwaitTask - |> Async.Ignore - - let filePath = @"Files/tux.png" - use fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read) - - let fileName = - filePath.Split(Path.DirectorySeparatorChar) - |> Array.last - - do! - botClient.SendPhotoAsync( - chatId = ChatId(message.Chat.Id), - photo = InputFileStream(fileStream, fileName), - caption = "Nice Picture") - |> Async.AwaitTask - |> Async.Ignore - } - - let requestContactAndLocation = async { - let requestReplyKeyboard = - ReplyKeyboardMarkup(seq { - KeyboardButton.WithRequestLocation("Location"); - KeyboardButton.WithRequestContact("Contact"); - }, - ResizeKeyboard = true) - - do! - botClient.SendTextMessageAsync( - chatId = ChatId(message.Chat.Id), - text = "Who or Where are you?", - replyMarkup = requestReplyKeyboard) - |> Async.AwaitTask - |> Async.Ignore - } - - let usage = async { - let usage = - "Usage:\n" + - "/inline - send inline keyboard\n" + - "/keyboard - send custom keyboard\n" + - "/remove - remove custom keyboard\n" + - "/photo - send a photo\n" + - "/request - request location or contact" - - do! - botClient.SendTextMessageAsync( - chatId = ChatId(message.Chat.Id), - text = usage, - replyMarkup = ReplyKeyboardRemove()) - |> Async.AwaitTask - |> Async.Ignore - } - - async { - if message.Type <> MessageType.Text then - () - else - let fn = - // We use tryHead here just in case we get an empty - // response from the user - match message.Text.Split(' ') |> Array.tryHead with - | Some "/inline" -> sendInlineKeyboard - | Some "/keyboard" -> sendReplyKeyboard - | Some "/remove" -> removeKeyboard - | Some "/photo" -> sendFile - | Some "/request" -> requestContactAndLocation - | _ -> usage +open Telegram.Bot - do! fn - } +open FSharp.Examples.Polling.Services - let unknownUpdateHandlerAsync (botClient:ITelegramBotClient) (update:Update) = async { - Console.WriteLine($"Unknown update type: {update.Type}"); - } +type ReceiverSvc = ReceiverService - let handleUpdateAsync botClient (update:Update) cts = async { - try - let fn = - match update.Type with - | UpdateType.Message -> botOnMessageReceived botClient update.Message - | UpdateType.EditedMessage -> botOnMessageReceived botClient update.EditedMessage - | UpdateType.CallbackQuery -> botOnCallbackQueryReceived botClient update.CallbackQuery - | UpdateType.InlineQuery -> botOnInlineQueryReceived botClient update.InlineQuery - | UpdateType.ChosenInlineResult -> botOnChosenInlineResultReceived botClient update.ChosenInlineResult - | _ -> unknownUpdateHandlerAsync botClient update - return fn |> Async.Start - with - | ex -> do! handleErrorAsync botClient ex cts - } +type BotConfiguration() = + member val BotToken: string -type FuncConvert = - // There is quite some bit of jugglery here, so requires some explanation: - // DefaultUpdateHandler() requires two arguments, both of type Func<_,_,_,_>, and both - // of which return a Task. Now, in order to be in the F# domain, we would like to - // have this Func<> defined as an F# function: and so we need to explicitely construct - // Func<>s by passing them to an inner lambda function. - // Now, the inner lambda function needs to return a Task, but we want to use F# async. - // We therefore use a Async.StartAsTask to start an async computation and get back a Task. - // The last ":>" is for upcasting Task (returned by the async computations) to their - // base class Task to avoid and error that says "the expression expects a Task but we have a - // Task here.". - // The good thing about doing all this is that handleUpdateAsync and handleErrorAsync are both - // in the F# domain - so now can take all advantages of F#! - static member inline ToCSharpDelegate (f) = - Func<_,_,_,_>(fun a b c -> Async.StartAsTask (f a b c) :> Task) +module Program = + let createHostBuilder args = + Host.CreateDefaultBuilder(args) + .ConfigureServices(fun context services -> -module TelegramBot = - [] - let main argv = - let botClient - = TelegramBotClient(TelegramBotCfg.token) + let cfg = context.Configuration.GetSection(nameof(BotConfiguration)) + let token = cfg.["BotToken"] - async { - let! me = botClient.GetMeAsync() |> Async.AwaitTask - printfn $"Hello, World! I am user {me.Id} and my name is {me.FirstName}." - printfn $"Start listening for {me.Username}..." + // Register named HttpClient to benefits from IHttpClientFactory + // and consume it with ITelegramBotClient typed client. + // More read: + // https://docs.microsoft.com/en-us/aspnet/core/fundamentals/http-requests?view=aspnetcore-5.0#typed-clients + // https://docs.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests + services.AddHttpClient("telegram_bot_client").AddTypedClient( + fun httpClient sp -> + let options = TelegramBotClientOptions(token) + TelegramBotClient(options, httpClient) :> ITelegramBotClient + ) |> ignore - use cts = new CancellationTokenSource(); + services.AddScoped() |> ignore + services.AddScoped() |> ignore + services.AddHostedService>() |> ignore) - botClient.StartReceiving( - Handlers.handleUpdateAsync |> FuncConvert.ToCSharpDelegate, - Handlers.handleErrorAsync |> FuncConvert.ToCSharpDelegate, - ReceiverOptions( AllowedUpdates = [||] ), - cts.Token) - } |> Async.RunSynchronously + [] + let main args = + createHostBuilder(args).Build().Run() - printfn "Press to exit" - Console.Read() |> ignore - 0 + 0 // exit code diff --git a/FSharp.Examples.Polling/Properties/launchSettings.json b/FSharp.Examples.Polling/Properties/launchSettings.json new file mode 100644 index 00000000..1181dfad --- /dev/null +++ b/FSharp.Examples.Polling/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "FSharp.Examples.Polling": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/FSharp.Examples.Polling/Services/Internal/IReceiverService.fs b/FSharp.Examples.Polling/Services/Internal/IReceiverService.fs new file mode 100644 index 00000000..fe1b3483 --- /dev/null +++ b/FSharp.Examples.Polling/Services/Internal/IReceiverService.fs @@ -0,0 +1,15 @@ +// Receiver Service Interface +// +// All receivers must implement this interface +// +// Copyright (c) 2023 Arvind Devarajan +// Licensed to you under the MIT License. +// See the LICENSE file in the project root for more information. + +namespace FSharp.Examples.Polling.Services.Internal + +open System.Threading +open System.Threading.Tasks + +type IReceiverService = + abstract member ReceiveAsync: CancellationToken -> Async diff --git a/FSharp.Examples.Polling/Services/Internal/UpdateHandlerFuncs.fs b/FSharp.Examples.Polling/Services/Internal/UpdateHandlerFuncs.fs new file mode 100644 index 00000000..7395dd0d --- /dev/null +++ b/FSharp.Examples.Polling/Services/Internal/UpdateHandlerFuncs.fs @@ -0,0 +1,233 @@ +// Implementation of all the Update Handlers +// +// This file contains tha actual F# implementations of all +// handlers, using the idiomatic patterns in F#. +// +// Copyright (c) 2023 Arvind Devarajan +// Licensed to you under the MIT License. +// See the LICENSE file in the project root for more information. + +namespace FSharp.Examples.Polling.Services.Internal + +open System +open System.IO +open System.Threading + +open Microsoft.Extensions.Logging + +open Telegram.Bot +open Telegram.Bot.Exceptions +open Telegram.Bot.Types +open Telegram.Bot.Types.Enums +open Telegram.Bot.Types.InlineQueryResults +open Telegram.Bot.Types.ReplyMarkups + +open FSharp.Examples.Polling.Util + +module UpdateHandlerFuncs = + module private BotTextMessages = + let sendInlineKeyboard (botClient:ITelegramBotClient) (logger: ILogger) (cts: CancellationToken) (message:Message) = + botClient.SendChatActionAsync( + chatId = message.Chat.Id, + chatAction = ChatAction.Typing, + cancellationToken = cts) |> Async.AwaitTask |> ignore + + let inlineKeyboard = seq { + // first row + seq { + InlineKeyboardButton.WithCallbackData("1.1", "11"); + InlineKeyboardButton.WithCallbackData("1.2", "12"); + } + + // second row + seq { + InlineKeyboardButton.WithCallbackData("2.1", "21"); + InlineKeyboardButton.WithCallbackData("2.2", "22"); + } + } + + botClient.SendTextMessageAsync( + chatId = message.Chat.Id, + text = "Choose", + replyMarkup = InlineKeyboardMarkup(inlineKeyboard), + cancellationToken = cts) + |> Async.AwaitTask |> Async.Ignore + + let sendReplyKeyboard (botClient:ITelegramBotClient) (logger: ILogger) (cts: CancellationToken) (message:Message) = + let replyKeyboardMarkup = + ReplyKeyboardMarkup( seq { + // first row + seq { KeyboardButton("1.1"); KeyboardButton("1.2") }; + + // second row + seq { KeyboardButton("1.1"); KeyboardButton("1.2") }; + }, + ResizeKeyboard = true) + + botClient.SendTextMessageAsync( + chatId = message.Chat.Id, + text = "Choose", + replyMarkup = replyKeyboardMarkup, + cancellationToken = cts) + |> Async.AwaitTask |> Async.Ignore + + let removeKeyboard (botClient:ITelegramBotClient) (logger: ILogger) (cts: CancellationToken) (message:Message) = + botClient.SendTextMessageAsync( + chatId = message.Chat.Id, + text = "Removing keyboard", + replyMarkup = ReplyKeyboardRemove(), + cancellationToken = cts) + |> Async.AwaitTask |> Async.Ignore + + let sendFile (botClient:ITelegramBotClient) (logger: ILogger) (cts: CancellationToken) (message:Message) = + botClient.SendChatActionAsync( + message.Chat.Id, + ChatAction.UploadPhoto, + cancellationToken = cts) + |> Async.AwaitTask |> ignore + + let filePath = @"Files/tux.png" + let fileName = + filePath.Split(Path.DirectorySeparatorChar) + |> Array.last + let inputStream = + InputFileStream(new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read), + fileName) + + botClient.SendPhotoAsync( + chatId = message.Chat.Id, + photo = inputStream, + caption = "Nice Picture", + cancellationToken = cts) + |> Async.AwaitTask |> Async.Ignore + + let requestContactAndLocation (botClient:ITelegramBotClient) (logger: ILogger) (cts: CancellationToken) (message:Message) = + let requestReplyKeyboard = + ReplyKeyboardMarkup(seq { + KeyboardButton.WithRequestLocation("Location"); + KeyboardButton.WithRequestContact("Contact"); + }, + ResizeKeyboard = true) + + botClient.SendTextMessageAsync( + chatId = message.Chat.Id, + text = "Who or Where are you?", + replyMarkup = requestReplyKeyboard, + cancellationToken = cts) + |> Async.AwaitTask |> Async.Ignore + + let startInlineQuery (botClient:ITelegramBotClient) (logger: ILogger) (cts: CancellationToken) (message:Message) = + let inlineKeyboard = + InlineKeyboardMarkup (InlineKeyboardButton.WithSwitchInlineQueryCurrentChat("Inline Mode")) + + botClient.SendTextMessageAsync( + chatId = message.Chat.Id, + text = "Press the button to start Inline Query", + replyMarkup = inlineKeyboard, + cancellationToken = cts) + |> Async.AwaitTask |> Async.Ignore + + let failingHandler (botClient:ITelegramBotClient) (logger: ILogger) (cts: CancellationToken) (message:Message) = + raise <| IndexOutOfRangeException() + + let usage (botClient:ITelegramBotClient) (logger: ILogger) (cts: CancellationToken) (message:Message) = + let usage = + "Usage:\n" + + "/inline_keyboard - send inline keyboard\n" + + "/keyboard - send custom keyboard\n" + + "/remove - remove custom keyboard\n" + + "/photo - send a photo\n" + + "/request - request location or contact\n" + + "/inline_mode - send keyboard with inline query\n" + + "/throw - Simulate a failed bot message handler\n" + + botClient.SendTextMessageAsync( + chatId = message.Chat.Id, + text = usage, + replyMarkup = ReplyKeyboardRemove(), + cancellationToken = cts) + |> Async.AwaitTask |> Async.Ignore + + let botOnMessageReceived (botClient:ITelegramBotClient) (logger: ILogger) (cts: CancellationToken) (message:Message) = + let handleBotMessage f = f botClient logger cts message + + logInfo logger $"Receive message type: {message.Type}" + + match message.Text with + | text when message.Text <> "" -> + // We use tryHead here just in case we get an empty + // response from the user + match text.Split(' ') |> Array.tryHead with + | Some "/inline_keyboard" -> handleBotMessage BotTextMessages.sendInlineKeyboard + | Some "/keyboard" -> handleBotMessage BotTextMessages.sendReplyKeyboard + | Some "/remove" -> handleBotMessage BotTextMessages.removeKeyboard + | Some "/photo" -> handleBotMessage BotTextMessages.sendFile + | Some "/request" -> handleBotMessage BotTextMessages.requestContactAndLocation + | Some "/inline_mode" -> handleBotMessage BotTextMessages.startInlineQuery + | Some "/throw" -> handleBotMessage BotTextMessages.failingHandler + | _ -> handleBotMessage BotTextMessages.usage + | _ -> async { return () } + + let botOnCallbackQueryReceived (botClient:ITelegramBotClient) (logger: ILogger) (cts: CancellationToken) (query:CallbackQuery) = + botClient.AnswerCallbackQueryAsync(query.Id, $"Received {query.Data}") |> Async.AwaitTask |> ignore + + botClient.SendTextMessageAsync( + chatId = query.Message.Chat.Id, + text = $"Received {query.Data}", + cancellationToken = cts) |> Async.AwaitTask |> Async.Ignore + + + let botOnInlineQueryReceived (botClient:ITelegramBotClient) (logger: ILogger) (cts: CancellationToken) (inlinequery:InlineQuery) = + logInfo logger $"Received inline query from: {inlinequery.From.Id}" + + // displayed result + let results = seq { + InlineQueryResultArticle( + id = "3", + title = "TgBots", + inputMessageContent = InputTextMessageContent("hello")) + } + + botClient.AnswerInlineQueryAsync(inlinequery.Id, + results |> Seq.cast, + isPersonal = true, + cacheTime = 0, + cancellationToken = cts) + |> Async.AwaitTask + + let botOnChosenInlineResultReceived (botClient:ITelegramBotClient) (logger: ILogger) (cts: CancellationToken) (chosenInlineResult:ChosenInlineResult) = + async { + logInfo logger $"Received inline result: {chosenInlineResult.ResultId}" + } + + let unknownUpdateHandlerAsync (botClient:ITelegramBotClient) (logger: ILogger) (cts: CancellationToken) (update:Update) = + async { + logInfo logger $"Unknown update type: {update.Type}" + } + + let handlePollingErrorAsync _ (logger: ILogger) _ (err:Exception) = async { + let errormsg = + match err with + | :? ApiRequestException as apiex -> $"Telegram API Error:\n[{apiex.ErrorCode}]\n{apiex.Message}" + | _ -> err.ToString() + + logInfo logger $"{errormsg}" + } + + let handleUpdateAsync botClient logger cts (update:Update) = async { + let handleUpdate f = f botClient logger cts + + try + let fn = + match update.Type with + | UpdateType.Message -> handleUpdate botOnMessageReceived update.Message + | UpdateType.EditedMessage -> handleUpdate botOnMessageReceived update.EditedMessage + | UpdateType.CallbackQuery -> handleUpdate botOnCallbackQueryReceived update.CallbackQuery + | UpdateType.InlineQuery -> handleUpdate botOnInlineQueryReceived update.InlineQuery + | UpdateType.ChosenInlineResult -> handleUpdate botOnChosenInlineResultReceived update.ChosenInlineResult + | _ -> handleUpdate unknownUpdateHandlerAsync update + + return! fn + with + | ex -> do! handleUpdate handlePollingErrorAsync ex + } diff --git a/FSharp.Examples.Polling/Services/PollingService.fs b/FSharp.Examples.Polling/Services/PollingService.fs new file mode 100644 index 00000000..415b4b8a --- /dev/null +++ b/FSharp.Examples.Polling/Services/PollingService.fs @@ -0,0 +1,48 @@ +// The worker entry-point +// +// Background service consuming a scoped service. +// See more: https://docs.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services#consuming-a-scoped-service-in-a-background-task +// +// This file simply forms that "link" between the .NET +// class to the actual service functions implemented in +// FSharp.Examples.Polling.Services.Internal.PollingServiceFuncs +// +// Copyright (c) 2023 Arvind Devarajan +// Licensed to you under the MIT License. +// See the LICENSE file in the project root for more information. + +namespace FSharp.Examples.Polling.Services + +open System +open System.Threading + +open Microsoft.Extensions.DependencyInjection +open Microsoft.Extensions.Logging +open Microsoft.Extensions.Hosting + +open FSharp.Examples.Polling.Services.Internal +open FSharp.Examples.Polling.Util + +type PollingService<'T when 'T:> IReceiverService>(sp: IServiceProvider, logger: ILogger>) = + inherit BackgroundService() + + member __.doWork (cts: CancellationToken) = task { + let getReceiverService _ = + use scope = sp.CreateScope() + let service: 'T = scope.ServiceProvider.GetRequiredService<'T>() + service + + let cancellationNotRequested _ = (not cts.IsCancellationRequested) + + try + return Seq.initInfinite getReceiverService + |> Seq.takeWhile cancellationNotRequested + |> Seq.iter (fun r -> (r.ReceiveAsync cts |> Async.RunSynchronously |> ignore)) + with + | e -> + logger.LogError($"Polling failed with exception: {e.ToString}: {e.Message}"); + } + + override __.ExecuteAsync(cts: CancellationToken) = + logInfo logger "Starting polling service" + __.doWork cts diff --git a/FSharp.Examples.Polling/Services/ReceiverService.fs b/FSharp.Examples.Polling/Services/ReceiverService.fs new file mode 100644 index 00000000..3bb7620d --- /dev/null +++ b/FSharp.Examples.Polling/Services/ReceiverService.fs @@ -0,0 +1,50 @@ +// Receiver to receive messages from user +// +// The received messages are also displatched to +// the update handler to get responses to be sent +// back to the user +// +// Copyright (c) 2023 Arvind Devarajan +// Licensed to you under the MIT License. +// See the LICENSE file in the project root for more information. +namespace FSharp.Examples.Polling.Services + +open System.Threading +open System.Threading.Tasks +open Microsoft.Extensions.Logging +open Telegram.Bot.Polling +open Telegram.Bot.Types +open Telegram.Bot + +open FSharp.Examples.Polling.Services.Internal + +open FSharp.Examples.Polling.Util + +type ReceiverService<'T when 'T :> IUpdateHandler>(botClient: ITelegramBotClient, updateHandler: UpdateHandler, logger: ILogger<'T>) = + interface IReceiverService with + member __.ReceiveAsync(cts: CancellationToken) = async { + + logInfo logger "ReceiveAsync called" + + let options = ReceiverOptions( + AllowedUpdates = [||], + ThrowPendingUpdates = true + ) + + try + let! me = botClient.GetMeAsync(cts) |> Async.AwaitTask + let username = + match me.Username with + | null -> "My Awesome Bot" + | v -> v + + logInfo logger $"Start receiving updates for {username}" + + return! botClient.ReceiveAsync( + updateHandler = updateHandler, + receiverOptions = options, + cancellationToken = cts) |> Async.AwaitTask + with + | :? TaskCanceledException -> logInfo logger "INFO: Receive cancelled." + | e -> logInfo logger $"ERROR: {e.Message}" + } diff --git a/FSharp.Examples.Polling/Services/UpdateHandler.fs b/FSharp.Examples.Polling/Services/UpdateHandler.fs new file mode 100644 index 00000000..9eac9212 --- /dev/null +++ b/FSharp.Examples.Polling/Services/UpdateHandler.fs @@ -0,0 +1,28 @@ +// Update Handler class inherited from IUpdateHandler +// +// This file simply forms that "link" between the .NET +// class to the actual implementation of the handlers +// as seen in the module +// FSharp.Examples.Polling.Services.Internal.UpdateHandlerFuncs +// +// Copyright (c) 2023 Arvind Devarajan +// Licensed to you under the MIT License. +// See the LICENSE file in the project root for more information. + +namespace FSharp.Examples.Polling.Services + +open System +open System.Threading +open Microsoft.Extensions.Logging +open Telegram.Bot +open Telegram.Bot.Polling + +open FSharp.Examples.Polling.Services.Internal + +type UpdateHandler(botClient: ITelegramBotClient, logger: ILogger) = + interface IUpdateHandler with + member __.HandleUpdateAsync( _ , update, cancellation) = + UpdateHandlerFuncs.handleUpdateAsync botClient logger cancellation update |> Async.StartAsTask :> Tasks.Task + + member __.HandlePollingErrorAsync( _ , ex: Exception, cancellationToken: CancellationToken) = + UpdateHandlerFuncs.handlePollingErrorAsync botClient logger cancellationToken ex |> Async.StartAsTask :> Tasks.Task diff --git a/FSharp.Examples.Polling/Util.fs b/FSharp.Examples.Polling/Util.fs new file mode 100644 index 00000000..e78d6eb8 --- /dev/null +++ b/FSharp.Examples.Polling/Util.fs @@ -0,0 +1,18 @@ +// Utility functions +// +// Copyright (c) 2023 Arvind Devarajan +// Licensed to you under the MIT License. +// See the LICENSE file in the project root for more information. + +namespace FSharp.Examples.Polling + +open System.Threading +open System.Threading.Tasks +open Microsoft.Extensions.Logging + +module Util = + // Placeholder for unimplemented functions + let undefined<'T> : 'T = failwith "Not implemented yet" + + // Log information using the passed-in logger + let logInfo (logger: ILogger) (msg: string) = logger.LogInformation msg diff --git a/FSharp.Examples.Polling/appsettings.Development.json b/FSharp.Examples.Polling/appsettings.Development.json new file mode 100644 index 00000000..6203dc3d --- /dev/null +++ b/FSharp.Examples.Polling/appsettings.Development.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "BotConfiguration": { + "BotToken": "{BOT_TOKEN}" + } +} diff --git a/FSharp.Examples.Polling/appsettings.json b/FSharp.Examples.Polling/appsettings.json new file mode 100644 index 00000000..6203dc3d --- /dev/null +++ b/FSharp.Examples.Polling/appsettings.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "BotConfiguration": { + "BotToken": "{BOT_TOKEN}" + } +}