diff --git a/README.md b/README.md index 04c3007..e18aa27 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -# telega +# Telega [![Package Version](https://img.shields.io/hexpm/v/telega)](https://hex.pm/packages/telega) [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/telega/) A [Gleam](https://gleam.run/) library for the Telegram Bot API. -## It provides: +## It provides - an interface to the Telegram Bot HTTP-based APIs `telega/api` - adapter to use with [wisp](https://github.com/gleam-wisp/wisp) @@ -35,7 +35,7 @@ import gleam/result import mist import telega import telega/adapters/wisp as telega_wisp -import telega/api as telega_api +import telega/reply import telega/update.{CommandUpdate, TextUpdate} import wisp @@ -48,8 +48,8 @@ fn echo_handler(ctx) { use <- telega.log_context(ctx, "echo") case ctx.update { - TextUpdate(text: text, ..) -> telega_api.reply(ctx, text) - CommandUpdate(command: command, ..) -> telega_api.reply(ctx, command.text) + TextUpdate(text: text, ..) -> reply.with_text(ctx, text) + CommandUpdate(command: command, ..) -> reply.with_text(ctx, command.text) _ -> Error("No text message") } |> result.map(fn(_) { Nil }) diff --git a/examples/00-echo-bot/gleam.toml b/examples/00-echo-bot/gleam.toml index 5a0587d..31d80f1 100644 --- a/examples/00-echo-bot/gleam.toml +++ b/examples/00-echo-bot/gleam.toml @@ -4,10 +4,9 @@ description = "A Telega Echo Bot example" [dependencies] gleam_stdlib = "~> 0.34 or ~> 1.0" -mist = "~> 0.17" +mist = "~> 1.2.0" gleam_erlang = "~> 0.25" -wisp = "~> 0.12" -dotenv_gleam = "~> 1.0" +wisp = "~> 0.14" telega = { path = "../.." } [dev-dependencies] diff --git a/examples/00-echo-bot/src/bot.gleam b/examples/00-echo-bot/src/bot.gleam index df09175..e37dae7 100644 --- a/examples/00-echo-bot/src/bot.gleam +++ b/examples/00-echo-bot/src/bot.gleam @@ -4,7 +4,7 @@ import gleam/result import mist import telega import telega/adapters/wisp as telega_wisp -import telega/api as telega_api +import telega/reply import telega/update.{CommandUpdate, TextUpdate} import wisp @@ -17,8 +17,8 @@ fn echo_handler(ctx) { use <- telega.log_context(ctx, "echo") case ctx.update { - TextUpdate(text: text, ..) -> telega_api.reply(ctx, text) - CommandUpdate(command: command, ..) -> telega_api.reply(ctx, command.text) + TextUpdate(text: text, ..) -> reply.with_text(ctx, text) + CommandUpdate(command: command, ..) -> reply.with_text(ctx, command.text) _ -> Error("No text message") } |> result.map(fn(_) { Nil }) diff --git a/examples/01-commands-bot/gleam.toml b/examples/01-commands-bot/gleam.toml index 4982525..1a037b1 100644 --- a/examples/01-commands-bot/gleam.toml +++ b/examples/01-commands-bot/gleam.toml @@ -4,10 +4,10 @@ description = "A Telega Commands example" [dependencies] gleam_stdlib = "~> 0.34 or ~> 1.0" -mist = "~> 0.17" +mist = "~> 1.2.0" gleam_erlang = "~> 0.25" -wisp = "~> 0.12" -dotenv_gleam = "~> 1.0" +wisp = "~> 0.14" +dotenv_gleam = "1.0.5" telega = { path = "../.." } [dev-dependencies] diff --git a/examples/01-commands-bot/src/bot.gleam b/examples/01-commands-bot/src/bot.gleam index d361d86..3254edc 100644 --- a/examples/01-commands-bot/src/bot.gleam +++ b/examples/01-commands-bot/src/bot.gleam @@ -9,6 +9,7 @@ import telega/adapters/wisp as telega_wisp import telega/api as telega_api import telega/bot.{type Context} import telega/model as telega_model +import telega/reply import wisp.{type Request, type Response} type Bot = @@ -42,7 +43,7 @@ fn handle_request(bot: Bot, req: Request) -> Response { fn dice_command_handler(ctx: NilContext, _) -> Result(Nil, String) { use <- telega.log_context(ctx, "dice") - telega_api.send_dice(ctx, None) + reply.with_dice(ctx, None) |> result.map(fn(_) { Nil }) } @@ -50,12 +51,12 @@ fn start_command_handler(ctx: NilContext, _) -> Result(Nil, String) { use <- telega.log_context(ctx, "start") telega_api.set_my_commands( - ctx.config, + ctx.config.api, telega_model.bot_commands_from([#("/dice", "Roll a dice")]), None, ) |> result.then(fn(_) { - telega_api.reply( + reply.with_text( ctx, "Hello! I'm a dice bot. You can roll a dice by sending /dice command.", ) diff --git a/examples/02-session-bot/gleam.toml b/examples/02-session-bot/gleam.toml index f724d5a..9fe3bd6 100644 --- a/examples/02-session-bot/gleam.toml +++ b/examples/02-session-bot/gleam.toml @@ -4,10 +4,10 @@ description = "A Telega Session example" [dependencies] gleam_stdlib = "~> 0.34 or ~> 1.0" -mist = "~> 0.17" +mist = "~> 1.2.0" gleam_erlang = "~> 0.25" -wisp = "~> 0.12" -dotenv_gleam = "~> 1.0" +wisp = "~> 0.14" +dotenv_gleam = "1.0.5" telega = { path = "../.." } carpenter = "~> 0.3.1" diff --git a/examples/02-session-bot/src/bot.gleam b/examples/02-session-bot/src/bot.gleam index 5dfdb6c..5414aa2 100644 --- a/examples/02-session-bot/src/bot.gleam +++ b/examples/02-session-bot/src/bot.gleam @@ -11,6 +11,7 @@ import telega/adapters/wisp as telega_wisp import telega/api as telega_api import telega/bot.{type Context} import telega/model as telega_model +import telega/reply import wisp.{type Response} type BotContext = @@ -41,7 +42,7 @@ fn set_name_command_handler( use <- bool.guard(ctx.session.state != WaitName, Ok(ctx.session)) use <- telega.log_context(ctx, "set_name command") - telega_api.reply(ctx, "What's your name?") + reply.with_text(ctx, "What's your name?") |> result.map(fn(_) { NameBotSession(name: ctx.session.name, state: SetName) }) } @@ -52,7 +53,7 @@ fn set_name_message_handler( use <- bool.guard(ctx.session.state != SetName, Ok(ctx.session)) use <- telega.log_context(ctx, "set_name") - telega_api.reply(ctx, "Your name is: " <> name <> " set!") + reply.with_text(ctx, "Your name is: " <> name <> " set!") |> result.map(fn(_) { NameBotSession(name: name, state: WaitName) }) } @@ -62,7 +63,7 @@ fn get_name_command_handler( ) -> Result(NameBotSession, String) { use <- telega.log_context(ctx, "get_name command") - telega_api.reply(ctx, "Your name is: " <> ctx.session.name) + reply.with_text(ctx, "Your name is: " <> ctx.session.name) |> result.map(fn(_) { ctx.session }) } @@ -70,7 +71,7 @@ fn start_command_handler(ctx, _) -> Result(NameBotSession, String) { use <- telega.log_context(ctx, "start") telega_api.set_my_commands( - ctx.config, + ctx.config.api, telega_model.bot_commands_from([ #("/set_name", "Set name"), #("/get_name", "Get name"), @@ -78,7 +79,7 @@ fn start_command_handler(ctx, _) -> Result(NameBotSession, String) { None, ) |> result.then(fn(_) { - telega_api.reply( + reply.with_text( ctx, "Hello! I'm a Name bot. You can set your name with /set_name command.", ) diff --git a/examples/03-conversation-bot/README.md b/examples/03-conversation-bot/README.md index cac092a..5f6f5d4 100644 --- a/examples/03-conversation-bot/README.md +++ b/examples/03-conversation-bot/README.md @@ -5,4 +5,4 @@ gleam run # Run the server gleam test # Run the tests ``` -This is an example of a simple Session bot using the Telega library. +This is an example of a simple Conversation bot using the Telega library. diff --git a/examples/03-conversation-bot/gleam.toml b/examples/03-conversation-bot/gleam.toml index f724d5a..9fe3bd6 100644 --- a/examples/03-conversation-bot/gleam.toml +++ b/examples/03-conversation-bot/gleam.toml @@ -4,10 +4,10 @@ description = "A Telega Session example" [dependencies] gleam_stdlib = "~> 0.34 or ~> 1.0" -mist = "~> 0.17" +mist = "~> 1.2.0" gleam_erlang = "~> 0.25" -wisp = "~> 0.12" -dotenv_gleam = "~> 1.0" +wisp = "~> 0.14" +dotenv_gleam = "1.0.5" telega = { path = "../.." } carpenter = "~> 0.3.1" diff --git a/examples/03-conversation-bot/src/bot.gleam b/examples/03-conversation-bot/src/bot.gleam index 1f972f4..94df366 100644 --- a/examples/03-conversation-bot/src/bot.gleam +++ b/examples/03-conversation-bot/src/bot.gleam @@ -10,6 +10,7 @@ import telega/adapters/wisp as telega_wisp import telega/api as telega_api import telega/bot.{type Context} import telega/model as telega_model +import telega/reply import wisp.{type Response} type BotContext = @@ -38,9 +39,9 @@ fn set_name_command_handler( _, ) -> Result(NameBotSession, String) { use <- telega.log_context(ctx, "set_name command") - use _ <- result.try(telega_api.reply(ctx, "What's your name?")) + use _ <- result.try(reply.with_text(ctx, "What's your name?")) use ctx, name <- telega.wait_text(ctx) - use _ <- result.try(telega_api.reply(ctx, "Your name is: " <> name <> " set!")) + use _ <- result.try(reply.with_text(ctx, "Your name is: " <> name <> " set!")) Ok(NameBotSession(name: name)) } @@ -51,7 +52,7 @@ fn get_name_command_handler( ) -> Result(NameBotSession, String) { use <- telega.log_context(ctx, "get_name command") - telega_api.reply(ctx, "Your name is: " <> ctx.session.name) + reply.with_text(ctx, "Your name is: " <> ctx.session.name) |> result.map(fn(_) { ctx.session }) } @@ -59,7 +60,7 @@ fn start_command_handler(ctx, _) -> Result(NameBotSession, String) { use <- telega.log_context(ctx, "start") telega_api.set_my_commands( - ctx.config, + ctx.config.api, telega_model.bot_commands_from([ #("/set_name", "Set name"), #("/get_name", "Get name"), @@ -67,7 +68,7 @@ fn start_command_handler(ctx, _) -> Result(NameBotSession, String) { None, ) |> result.then(fn(_) { - telega_api.reply( + reply.with_text( ctx, "Hello! I'm a Name bot. You can set your name with /set_name command.", ) diff --git a/examples/04-keyboard-bot/README.md b/examples/04-keyboard-bot/README.md index cac092a..37f7196 100644 --- a/examples/04-keyboard-bot/README.md +++ b/examples/04-keyboard-bot/README.md @@ -1,8 +1,8 @@ -# Telega Conversation Bot +# Telega Bot With Keyboard ```sh gleam run # Run the server gleam test # Run the tests ``` -This is an example of a simple Session bot using the Telega library. +This is an example of a simple bot with keyboard using the Telega library. diff --git a/examples/04-keyboard-bot/gleam.toml b/examples/04-keyboard-bot/gleam.toml index f724d5a..9fe3bd6 100644 --- a/examples/04-keyboard-bot/gleam.toml +++ b/examples/04-keyboard-bot/gleam.toml @@ -4,10 +4,10 @@ description = "A Telega Session example" [dependencies] gleam_stdlib = "~> 0.34 or ~> 1.0" -mist = "~> 0.17" +mist = "~> 1.2.0" gleam_erlang = "~> 0.25" -wisp = "~> 0.12" -dotenv_gleam = "~> 1.0" +wisp = "~> 0.14" +dotenv_gleam = "1.0.5" telega = { path = "../.." } carpenter = "~> 0.3.1" diff --git a/examples/04-keyboard-bot/src/bot.gleam b/examples/04-keyboard-bot/src/bot.gleam index 865ac7f..231867c 100644 --- a/examples/04-keyboard-bot/src/bot.gleam +++ b/examples/04-keyboard-bot/src/bot.gleam @@ -12,6 +12,7 @@ import telega/api as telega_api import telega/bot.{type Context} import telega/keyboard as telega_keyboard import telega/model.{EditMessageTextParameters, Stringed} as telega_model +import telega/reply import wisp.{type Response} type BotContext = @@ -66,7 +67,7 @@ fn change_languages_keyboard( let language = ctx.session.lang let keyboard = language_keyboard.new_keyboard(language) - use _ <- result.try(telega_api.reply_with_markup( + use _ <- result.try(reply.with_markup( ctx, t_change_language_message(language), telega_keyboard.build(keyboard), @@ -74,10 +75,7 @@ fn change_languages_keyboard( use _, text <- telega.wait_hears(ctx, telega_keyboard.hear(keyboard)) let language = language_keyboard.option_to_language(text) - use _ <- result.try(telega_api.reply( - ctx, - t_language_changed_message(language), - )) + use _ <- result.try(reply.with_text(ctx, t_language_changed_message(language))) Ok(LanguageBotSession(language)) } @@ -90,7 +88,7 @@ fn handle_inline_change_language( let language = ctx.session.lang let callback_data = language_keyboard.build_keyboard_callback_data() let keyboard = language_keyboard.new_inline_keyboard(language, callback_data) - use message <- result.try(telega_api.reply_with_markup( + use message <- result.try(reply.with_markup( ctx, t_change_language_message(language), telega_keyboard.build_inline(keyboard), @@ -106,12 +104,12 @@ fn handle_inline_change_language( let language = language_callback.data - use _ <- result.try(telega_api.answer_callback_query( + use _ <- result.try(reply.answer_callback_query( ctx, telega_model.new_answer_callback_query_parameters(callback_query_id), )) - use _ <- result.try(telega_api.edit_message_text( + use _ <- result.try(reply.edit_message_text( ctx, EditMessageTextParameters( ..telega_model.default_edit_message_text_parameters(), @@ -131,7 +129,7 @@ fn start_command_handler( use <- telega.log_context(ctx, "start") telega_api.set_my_commands( - ctx.config, + ctx.config.api, telega_model.bot_commands_from([ #("/lang", "Shows custom keyboard with languages"), #("/lang_inline", "Change language inline"), @@ -139,7 +137,7 @@ fn start_command_handler( None, ) |> result.then(fn(_) { - telega_api.reply(ctx, t_welcome_message(ctx.session.lang)) + reply.with_text(ctx, t_welcome_message(ctx.session.lang)) |> result.map(fn(_) { ctx.session }) }) } diff --git a/src/telega.gleam b/src/telega.gleam index dd2de44..ba76437 100644 --- a/src/telega.gleam +++ b/src/telega.gleam @@ -5,14 +5,13 @@ import gleam/otp/actor import gleam/otp/supervisor import gleam/result import gleam/string -import telega/api import telega/bot.{ type CallbackQueryFilter, type Context, type Handler, type Hears, type RegistryMessage, type SessionSettings, CallbackQueryFilter, Context, HandleAll, HandleBotRegistryMessage, HandleCallbackQuery, HandleCommand, HandleCommands, HandleHears, HandleText, SessionSettings, } -import telega/config.{type Config} +import telega/internal/config.{type Config} import telega/log import telega/update.{type Command, type Update} @@ -33,14 +32,14 @@ pub opaque type TelegaBuilder(session) { /// /// Usefull if you plan to implement own adapter. pub fn is_webhook_path(telega: Telega(session), path: String) -> Bool { - config.get_webhook_path(telega.config) == path + telega.config.webhook_path == path } /// Check if a secret token is valid. /// /// Usefull if you plan to implement own adapter. pub fn is_secret_token_valid(telega: Telega(session), token: String) -> Bool { - config.get_secret_token(telega.config) == token + telega.config.secret_token == token } /// Create a new Telega instance. @@ -261,7 +260,7 @@ pub fn init_nil_session( /// It will set the webhook and start the `Registry`. pub fn init(builder: TelegaBuilder(session)) -> Result(Telega(session), String) { let TelegaBuilder(telega) = builder - use is_ok <- result.try(api.set_webhook(telega.config)) + use is_ok <- result.try(bot.set_webhook(telega.config)) use <- bool.guard(!is_ok, Error("Failed to set webhook")) let session_settings = diff --git a/src/telega/api.gleam b/src/telega/api.gleam index 19d2512..f52897e 100644 --- a/src/telega/api.gleam +++ b/src/telega/api.gleam @@ -1,3 +1,7 @@ +//// This module provides an interface for interacting with the Telegram Bot API. +//// It will be useful if you want to interact with the Telegram Bot API directly, without running a bot. +//// But it will be more convenient to use the `reply` module in bot handlers. + import gleam/dynamic.{type DecodeError, type Dynamic} import gleam/erlang/process import gleam/http.{Get, Post} @@ -8,19 +12,36 @@ import gleam/json import gleam/option.{type Option, None, Some} import gleam/result import gleam/string -import telega/bot.{type Context} -import telega/config.{type Config} import telega/log import telega/model.{ type AnswerCallbackQueryParameters, type BotCommand, type BotCommandParameters, type EditMessageTextParameters, type EditMessageTextResult, - type ForwardMessageParameters, type Message as ModelMessage, type ReplyMarkup, + type ForwardMessageParameters, type Message as ModelMessage, type SendDiceParameters, type SendMessageParameters, type User, type WebhookInfo, } const default_retry_delay = 1000 +pub type TelegramApiConfig { + TelegramApiConfig( + token: String, + /// The maximum number of times to retry sending a API message. Default is 3. + max_retry_attempts: Int, + /// The Telegram Bot API URL. Default is "https://api.telegram.org". + /// This is useful for running [a local server](https://core.telegram.org/bots/api#using-a-local-bot-api-server). + tg_api_url: String, + ) +} + +pub fn new_api_config(token token: String) -> TelegramApiConfig { + TelegramApiConfig( + token, + max_retry_attempts: 3, + tg_api_url: "https://api.telegram.org", + ) +} + type TelegramApiRequest { TelegramApiPostRequest( url: String, @@ -38,13 +59,12 @@ type ApiResponse(result) { /// Set the webhook URL using [setWebhook](https://core.telegram.org/bots/api#setwebhook) API. /// /// **Official reference:** https://core.telegram.org/bots/api#setwebhook -pub fn set_webhook(config config: Config) -> Result(Bool, String) { - let webhook_url = - config.get_server_url(config) <> "/" <> config.get_webhook_path(config) - let query = [ - #("url", webhook_url), - #("secret_token", config.get_secret_token(config)), - ] +pub fn set_webhook( + config config: TelegramApiConfig, + webhook_path webhook_path: String, +) -> Result(Bool, String) { + let webhook_url = config.tg_api_url <> "/" <> webhook_path + let query = [#("url", webhook_url), #("secret_token", config.token)] new_get_request(config, path: "setWebhook", query: Some(query)) |> fetch(config) @@ -54,7 +74,9 @@ pub fn set_webhook(config config: Config) -> Result(Bool, String) { /// Use this method to get current webhook status. /// /// **Official reference:** https://core.telegram.org/bots/api#getwebhookinfo -pub fn get_webhook_info(config config: Config) -> Result(WebhookInfo, String) { +pub fn get_webhook_info( + config config: TelegramApiConfig, +) -> Result(WebhookInfo, String) { new_get_request(config, path: "getWebhookInfo", query: None) |> fetch(config) |> map_resonse(model.decode_webhook_info) @@ -63,7 +85,7 @@ pub fn get_webhook_info(config config: Config) -> Result(WebhookInfo, String) { /// Use this method to remove webhook integration if you decide to switch back to [getUpdates](https://core.telegram.org/bots/api#getupdates). /// /// **Official reference:** https://core.telegram.org/bots/api#deletewebhook -pub fn delete_webhook(config config: Config) -> Result(Bool, String) { +pub fn delete_webhook(config config: TelegramApiConfig) -> Result(Bool, String) { new_get_request(config, path: "deleteWebhook", query: None) |> fetch(config) |> map_resonse(dynamic.bool) @@ -71,7 +93,7 @@ pub fn delete_webhook(config config: Config) -> Result(Bool, String) { /// The same as [delete_webhook](#delete_webhook) but also drops all pending updates. pub fn delete_webhook_and_drop_updates( - config config: Config, + config config: TelegramApiConfig, ) -> Result(Bool, String) { new_get_request( config, @@ -87,7 +109,7 @@ pub fn delete_webhook_and_drop_updates( /// After a successful call, you can immediately log in on a local server, but will not be able to log in back to the cloud Bot API server for 10 minutes. /// /// **Official reference:** https://core.telegram.org/bots/api#logout -pub fn log_out(config config: Config) -> Result(Bool, String) { +pub fn log_out(config config: TelegramApiConfig) -> Result(Bool, String) { new_get_request(config, path: "logOut", query: None) |> fetch(config) |> map_resonse(dynamic.bool) @@ -98,51 +120,17 @@ pub fn log_out(config config: Config) -> Result(Bool, String) { /// The method will return error 429 in the first 10 minutes after the bot is launched. /// /// **Official reference:** https://core.telegram.org/bots/api#close -pub fn close(config config: Config) -> Result(Bool, String) { +pub fn close(config config: TelegramApiConfig) -> Result(Bool, String) { new_get_request(config, path: "close", query: None) |> fetch(config) |> map_resonse(dynamic.bool) } -/// Use this method to send text messages. -/// -/// **Official reference:** https://core.telegram.org/bots/api#sendmessage -pub fn reply( - ctx ctx: Context(session), - text text: String, -) -> Result(ModelMessage, String) { - reply_with_parameters( - ctx.config, - parameters: model.new_send_message_parameters( - text: text, - chat_id: model.Stringed(ctx.key), - ), - ) -} - -/// Use this method to send text messages with keyboard markup. -/// -/// **Official reference:** https://core.telegram.org/bots/api#sendmessage -pub fn reply_with_markup( - ctx ctx: Context(session), - text text: String, - markup reply_markup: ReplyMarkup, -) { - reply_with_parameters( - ctx.config, - parameters: model.new_send_message_parameters( - text: text, - chat_id: model.Stringed(ctx.key), - ) - |> model.set_send_message_parameters_reply_markup(reply_markup), - ) -} - /// Use this method to send text messages with additional parameters. /// /// **Official reference:** https://core.telegram.org/bots/api#sendmessage -pub fn reply_with_parameters( - config config: Config, +pub fn send_message( + config config: TelegramApiConfig, parameters parameters: SendMessageParameters, ) -> Result(ModelMessage, String) { let body_json = model.encode_send_message_parameters(parameters) @@ -161,7 +149,7 @@ pub fn reply_with_parameters( /// /// **Official reference:** https://core.telegram.org/bots/api#setmycommands pub fn set_my_commands( - config config: Config, + config config: TelegramApiConfig, commands commands: List(BotCommand), parameters parameters: Option(BotCommandParameters), ) -> Result(Bool, String) { @@ -198,7 +186,7 @@ pub fn set_my_commands( /// /// **Official reference:** https://core.telegram.org/bots/api#deletemycommands pub fn delete_my_commands( - config config: Config, + config config: TelegramApiConfig, parameters parameters: Option(BotCommandParameters), ) -> Result(Bool, String) { let parameters = @@ -221,7 +209,7 @@ pub fn delete_my_commands( /// /// **Official reference:** https://core.telegram.org/bots/api#getmycommands pub fn get_my_commands( - config config: Config, + config config: TelegramApiConfig, parameters parameters: Option(BotCommandParameters), ) -> Result(List(BotCommand), String) { let parameters = @@ -244,32 +232,27 @@ pub fn get_my_commands( /// /// **Official reference:** https://core.telegram.org/bots/api#senddice pub fn send_dice( - ctx ctx: Context(session), - parameters parameters: Option(SendDiceParameters), + config config: TelegramApiConfig, + parameters parameters: SendDiceParameters, ) -> Result(ModelMessage, String) { - let body_json = - parameters - |> option.lazy_unwrap(fn() { - model.new_send_dice_parameters(model.Stringed(ctx.key)) - }) - |> model.encode_send_dice_parameters + let body_json = model.encode_send_dice_parameters(parameters) new_post_request( - config: ctx.config, + config: config, path: "sendDice", query: None, body: json.to_string(body_json), ) - |> fetch(ctx.config) + |> fetch(config) |> map_resonse(model.decode_message) } /// A simple method for testing your bot's authentication token. /// /// **Official reference:** https://core.telegram.org/bots/api#getme -pub fn get_me(ctx ctx: Context(session)) -> Result(User, String) { - new_get_request(ctx.config, path: "getMe", query: None) - |> fetch(ctx.config) +pub fn get_me(config config: TelegramApiConfig) -> Result(User, String) { + new_get_request(config, path: "getMe", query: None) + |> fetch(config) |> map_resonse(model.decode_user) } @@ -279,18 +262,18 @@ pub fn get_me(ctx ctx: Context(session)) -> Result(User, String) { /// /// **Official reference:** https://core.telegram.org/bots/api#answercallbackquery pub fn answer_callback_query( - ctx ctx: Context(session), + config config: TelegramApiConfig, parameters parameters: AnswerCallbackQueryParameters, ) -> Result(Bool, String) { let body_json = model.encode_answer_callback_query_parameters(parameters) new_post_request( - config: ctx.config, + config: config, path: "answerCallbackQuery", query: None, body: json.to_string(body_json), ) - |> fetch(ctx.config) + |> fetch(config) |> map_resonse(dynamic.bool) } @@ -299,18 +282,18 @@ pub fn answer_callback_query( /// /// **Official reference:** https://core.telegram.org/bots/api#editmessagetext pub fn edit_message_text( - ctx ctx: Context(session), + config config: TelegramApiConfig, parameters parameters: EditMessageTextParameters, ) -> Result(EditMessageTextResult, String) { let body_json = model.encode_edit_message_text_parameters(parameters) new_post_request( - config: ctx.config, + config: config, path: "editMessageText", query: None, body: json.to_string(body_json), ) - |> fetch(ctx.config) + |> fetch(config) |> map_resonse(model.decode_edit_message_text_result) } @@ -319,30 +302,27 @@ pub fn edit_message_text( /// /// **Official reference:** https://core.telegram.org/bots/api#forwardmessage pub fn forward_message( - ctx ctx: Context(session), + config config: TelegramApiConfig, parameters parameters: ForwardMessageParameters, ) -> Result(ModelMessage, String) { let body_json = model.encode_forward_message_parameters(parameters) new_post_request( - config: ctx.config, + config: config, path: "forwardMessage", query: None, body: json.to_string(body_json), ) - |> fetch(ctx.config) + |> fetch(config) |> map_resonse(model.decode_message) } -fn build_url(configuration: Config, path: String) -> String { - config.get_tg_api_url(configuration) - <> config.get_token(configuration) - <> "/" - <> path +fn build_url(config: TelegramApiConfig, path: String) -> String { + config.tg_api_url <> config.token <> "/" <> path } fn new_post_request( - config config: Config, + config config: TelegramApiConfig, path path: String, body body: String, query query: Option(List(#(String, String))), @@ -351,7 +331,7 @@ fn new_post_request( } fn new_get_request( - config config: Config, + config config: TelegramApiConfig, path path: String, query query: Option(List(#(String, String))), ) { @@ -390,22 +370,6 @@ fn api_to_request( |> result.replace_error("Failed to convert API request to HTTP request") } -fn fetch( - api_request: TelegramApiRequest, - configuration: Config, -) -> Result(Response(String), String) { - use api_request <- result.try(api_to_request(api_request)) - let retry_count = config.get_max_retry_attempts(configuration) - - send_with_retry(api_request, retry_count) - |> result.map_error(fn(error) { - log.info("Api request failed with error:" <> string.inspect(error)) - - dynamic.string(error) - |> result.unwrap("Failed to send request") - }) -} - fn send_with_retry( api_request: Request(String), retries: Int, @@ -457,3 +421,19 @@ fn response_decoder(result_decoder: fn(Dynamic) -> Result(a, List(DecodeError))) dynamic.field("result", result_decoder), ) } + +// TODO: add rate limit handling +fn fetch( + api_request: TelegramApiRequest, + config: TelegramApiConfig, +) -> Result(Response(String), String) { + use api_request <- result.try(api_to_request(api_request)) + + send_with_retry(api_request, config.max_retry_attempts) + |> result.map_error(fn(error) { + log.info("Api request failed with error:" <> string.inspect(error)) + + dynamic.string(error) + |> result.unwrap("Failed to send request") + }) +} diff --git a/src/telega/bot.gleam b/src/telega/bot.gleam index 2048cc5..f6e66fe 100644 --- a/src/telega/bot.gleam +++ b/src/telega/bot.gleam @@ -9,77 +9,15 @@ import gleam/otp/supervisor import gleam/regex.{type Regex} import gleam/result import gleam/string -import telega/config.{type Config} +import telega/api +import telega/internal/config.{type Config} import telega/log import telega/update.{ type Command, type Update, CallbackQueryUpdate, CommandUpdate, TextUpdate, UnknownUpdate, } -pub type Handler(session) { - /// Handle all messages. - HandleAll(handler: fn(Context(session)) -> Result(session, String)) - /// Handle a specific command. - HandleCommand( - command: String, - handler: fn(Context(session), Command) -> Result(session, String), - ) - /// Handle multiple commands. - HandleCommands( - commands: List(String), - handler: fn(Context(session), Command) -> Result(session, String), - ) - /// Handle text messages. - HandleText(handler: fn(Context(session), String) -> Result(session, String)) - /// Handle text message with a specific substring. - HandleHears( - hears: Hears, - handler: fn(Context(session), String) -> Result(session, String), - ) - /// Handle callback query. Context, data from callback query and `callback_query_id` are passed to the handler. - HandleCallbackQuery( - filter: CallbackQueryFilter, - handler: fn(Context(session), String, String) -> Result(session, String), - ) -} - -pub type SessionSettings(session) { - SessionSettings( - // Calls after all handlers to persist the session. - persist_session: fn(String, session) -> Result(session, String), - // Calls on initialization of the bot instanse to get the session. - get_session: fn(String) -> Result(session, String), - ) -} - -pub type Hears { - HearText(text: String) - HearTexts(texts: List(String)) - HearRegex(regex: Regex) - HearRegexes(regexes: List(Regex)) -} - -pub type CallbackQueryFilter { - CallbackQueryFilter(re: Regex) -} - -/// Handlers context. -pub type Context(session) { - Context( - key: String, - update: Update, - config: Config, - session: session, - bot_subject: Subject(BotInstanseMessage(session)), - ) -} - -type RegistryItem(session) { - RegistryItem( - bot_subject: Subject(BotInstanseMessage(session)), - parent_subject: Subject(Subject(BotInstanseMessage(session))), - ) -} +// Registry -------------------------------------------------------------------- type Registry(session) { /// Registry works as routing for chat_id to bot instance. @@ -92,75 +30,15 @@ type Registry(session) { ) } -pub type RegistryMessage { - HandleBotRegistryMessage(update: Update) -} - -pub type BotInstanseMessage(session) { - BotInstanseMessageOk - BotInstanseMessageNew( - client: Subject(BotInstanseMessage(session)), - update: Update, - ) - BotInstanseMessageWaitHandler(handler: Handler(session)) -} - -type BotInstanse(session) { - BotInstanse( - key: String, - session: session, - config: Config, - handlers: List(Handler(session)), - session_settings: SessionSettings(session), - active_handler: Option(Handler(session)), - own_subject: Subject(BotInstanseMessage(session)), +type RegistryItem(session) { + RegistryItem( + bot_subject: Subject(BotInstanseMessage(session)), + parent_subject: Subject(Subject(BotInstanseMessage(session))), ) } -pub fn start_registry( - config: Config, - handlers: List(Handler(session)), - session_settings: SessionSettings(session), - parent_subject: Subject(Subject(RegistryMessage)), -) -> Result(Subject(RegistryMessage), actor.StartError) { - actor.start_spec(actor.Spec( - init: fn() { - let registry_subject = process.new_subject() - process.send(parent_subject, registry_subject) - - let selector = - process.new_selector() - |> process.selecting(registry_subject, function.identity) - - Registry( - bots: dict.new(), - config: config, - session_settings: session_settings, - handlers: handlers, - ) - |> actor.Ready(selector) - }, - loop: handle_registry_message, - init_timeout: 10_000, - )) -} - -pub fn wait_handler( - ctx: Context(session), - handler: Handler(session), -) -> Result(session, String) { - process.send(ctx.bot_subject, BotInstanseMessageWaitHandler(handler)) - Ok(ctx.session) -} - -fn new_context(bot: BotInstanse(session), update: Update) -> Context(session) { - Context( - update: update, - key: bot.key, - config: bot.config, - session: bot.session, - bot_subject: bot.own_subject, - ) +pub type RegistryMessage { + HandleBotRegistryMessage(update: Update) } fn try_send_update(registry_item: RegistryItem(session), update: Update) { @@ -247,6 +125,68 @@ fn add_bot_instance( } } +/// Set webhook for the bot. +pub fn set_webhook(config config: Config) -> Result(Bool, String) { + api.set_webhook(config.api, config.webhook_path) +} + +pub fn start_registry( + config: Config, + handlers: List(Handler(session)), + session_settings: SessionSettings(session), + parent_subject: Subject(Subject(RegistryMessage)), +) -> Result(Subject(RegistryMessage), actor.StartError) { + actor.start_spec(actor.Spec( + init: fn() { + let registry_subject = process.new_subject() + process.send(parent_subject, registry_subject) + + let selector = + process.new_selector() + |> process.selecting(registry_subject, function.identity) + + Registry( + bots: dict.new(), + config: config, + session_settings: session_settings, + handlers: handlers, + ) + |> actor.Ready(selector) + }, + loop: handle_registry_message, + init_timeout: 10_000, + )) +} + +pub fn wait_handler( + ctx: Context(session), + handler: Handler(session), +) -> Result(session, String) { + process.send(ctx.bot_subject, BotInstanseMessageWaitHandler(handler)) + Ok(ctx.session) +} + +fn new_context(bot: BotInstanse(session), update: Update) -> Context(session) { + Context( + update: update, + key: bot.key, + config: bot.config, + session: bot.session, + bot_subject: bot.own_subject, + ) +} + +// Session --------------------------------------------------------------------- + +pub type SessionSettings(session) { + SessionSettings( + // Calls after all handlers to persist the session. + persist_session: fn(String, session) -> Result(session, String), + // Calls on initialization of the bot instanse to get the session. + get_session: fn(String) -> Result(session, String), + ) +} + fn get_session_key(update: Update) -> Result(String, String) { case update { CommandUpdate(chat_id: chat_id, ..) -> Ok(int.to_string(chat_id)) @@ -301,6 +241,40 @@ fn start_bot_instanse( )) } +// Bot Instanse -------------------------------------------------------------------- + +/// Handlers context. +pub type Context(session) { + Context( + key: String, + update: Update, + config: Config, + session: session, + bot_subject: Subject(BotInstanseMessage(session)), + ) +} + +pub type BotInstanseMessage(session) { + BotInstanseMessageOk + BotInstanseMessageNew( + client: Subject(BotInstanseMessage(session)), + update: Update, + ) + BotInstanseMessageWaitHandler(handler: Handler(session)) +} + +type BotInstanse(session) { + BotInstanse( + key: String, + session: session, + config: Config, + handlers: List(Handler(session)), + session_settings: SessionSettings(session), + active_handler: Option(Handler(session)), + own_subject: Subject(BotInstanseMessage(session)), + ) +} + fn handle_bot_instanse_message( message: BotInstanseMessage(session), bot: BotInstanse(session), @@ -344,6 +318,15 @@ fn handle_bot_instanse_message( } } +// Hears ----------------------------------------------------------------------- + +pub type Hears { + HearText(text: String) + HearTexts(texts: List(String)) + HearRegex(regex: Regex) + HearRegexes(regexes: List(Regex)) +} + fn hears_check(text: String, hear: Hears) -> Bool { case hear { HearText(str) -> text == str @@ -353,6 +336,37 @@ fn hears_check(text: String, hear: Hears) -> Bool { } } +pub type Handler(session) { + /// Handle all messages. + HandleAll(handler: fn(Context(session)) -> Result(session, String)) + /// Handle a specific command. + HandleCommand( + command: String, + handler: fn(Context(session), Command) -> Result(session, String), + ) + /// Handle multiple commands. + HandleCommands( + commands: List(String), + handler: fn(Context(session), Command) -> Result(session, String), + ) + /// Handle text messages. + HandleText(handler: fn(Context(session), String) -> Result(session, String)) + /// Handle text message with a specific substring. + HandleHears( + hears: Hears, + handler: fn(Context(session), String) -> Result(session, String), + ) + /// Handle callback query. Context, data from callback query and `callback_query_id` are passed to the handler. + HandleCallbackQuery( + filter: CallbackQueryFilter, + handler: fn(Context(session), String, String) -> Result(session, String), + ) +} + +pub type CallbackQueryFilter { + CallbackQueryFilter(re: Regex) +} + fn do_handle( bot: BotInstanse(session), update: Update, diff --git a/src/telega/config.gleam b/src/telega/config.gleam deleted file mode 100644 index 0d92ba9..0000000 --- a/src/telega/config.gleam +++ /dev/null @@ -1,76 +0,0 @@ -import gleam/int -import gleam/option.{type Option, None} - -const telegram_url = "https://api.telegram.org/bot" - -const default_retry_count = 3 - -pub opaque type Config { - Config( - token: String, - server_url: String, - webhook_path: String, - /// String to compare to X-Telegram-Bot-Api-Secret-Token - secret_token: String, - /// The maximum number of times to retry sending a API message. Default is 3. - max_retry_attempts: Option(Int), - /// The Telegram Bot API URL. Default is "https://api.telegram.org". - /// This is useful for running [a local server](https://core.telegram.org/bots/api#using-a-local-bot-api-server). - tg_api_url: Option(String), - ) -} - -/// Creates a new Bot with the given options. -/// -/// If `secret_token` is not provided, a random one will be generated. -pub fn new( - token token: String, - url server_url: String, - webhook_path webhook_path: String, - secret_token secret_token: Option(String), -) -> Config { - let secret_token = - option.lazy_unwrap(secret_token, fn() { - int.random(1_000_000) - |> int.to_string - }) - - Config( - token: token, - server_url: server_url, - webhook_path: webhook_path, - secret_token: secret_token, - max_retry_attempts: None, - tg_api_url: None, - ) -} - -@internal -pub fn get_secret_token(config: Config) -> String { - config.secret_token -} - -@internal -pub fn get_tg_api_url(config: Config) -> String { - option.unwrap(config.tg_api_url, telegram_url) -} - -@internal -pub fn get_token(config: Config) -> String { - config.token -} - -@internal -pub fn get_max_retry_attempts(config: Config) -> Int { - option.unwrap(config.max_retry_attempts, default_retry_count) -} - -@internal -pub fn get_server_url(config: Config) -> String { - config.server_url -} - -@internal -pub fn get_webhook_path(config: Config) -> String { - config.webhook_path -} diff --git a/src/telega/internal/config.gleam b/src/telega/internal/config.gleam new file mode 100644 index 0000000..2eb95e5 --- /dev/null +++ b/src/telega/internal/config.gleam @@ -0,0 +1,44 @@ +import gleam/int +import gleam/option.{type Option} +import telega/api.{type TelegramApiConfig, TelegramApiConfig} + +const telegram_url = "https://api.telegram.org/bot" + +const default_retry_count = 3 + +pub type Config { + Config( + server_url: String, + webhook_path: String, + /// String to compare to X-Telegram-Bot-Api-Secret-Token + secret_token: String, + api: TelegramApiConfig, + ) +} + +/// Creates a new Bot with the given options. +/// +/// If `secret_token` is not provided, a random one will be generated. +pub fn new( + token token: String, + url server_url: String, + webhook_path webhook_path: String, + secret_token secret_token: Option(String), +) -> Config { + let secret_token = + option.lazy_unwrap(secret_token, fn() { + int.random(1_000_000) + |> int.to_string + }) + + Config( + server_url: server_url, + webhook_path: webhook_path, + secret_token: secret_token, + api: TelegramApiConfig( + token, + max_retry_attempts: default_retry_count, + tg_api_url: telegram_url, + ), + ) +} diff --git a/src/telega/reply.gleam b/src/telega/reply.gleam new file mode 100644 index 0000000..4d3a16f --- /dev/null +++ b/src/telega/reply.gleam @@ -0,0 +1,92 @@ +import gleam/option.{type Option} +import telega/api +import telega/bot.{type Context} +import telega/model.{ + type AnswerCallbackQueryParameters, type EditMessageTextParameters, + type EditMessageTextResult, type ForwardMessageParameters, + type Message as ModelMessage, type ReplyMarkup, type SendDiceParameters, +} + +/// Use this method to send text messages. +/// +/// **Official reference:** https://core.telegram.org/bots/api#sendmessage +pub fn with_text( + ctx ctx: Context(session), + text text: String, +) -> Result(ModelMessage, String) { + api.send_message( + ctx.config.api, + parameters: model.new_send_message_parameters( + text: text, + chat_id: model.Stringed(ctx.key), + ), + ) +} + +/// Use this method to send text messages with keyboard markup. +/// +/// **Official reference:** https://core.telegram.org/bots/api#sendmessage +pub fn with_markup( + ctx ctx: Context(session), + text text: String, + markup reply_markup: ReplyMarkup, +) { + api.send_message( + ctx.config.api, + parameters: model.new_send_message_parameters( + text: text, + chat_id: model.Stringed(ctx.key), + ) + |> model.set_send_message_parameters_reply_markup(reply_markup), + ) +} + +/// Use this method to send an animated emoji that will display a random value. +/// +/// **Official reference:** https://core.telegram.org/bots/api#senddice +pub fn with_dice( + ctx ctx: Context(session), + parameters parameters: Option(SendDiceParameters), +) -> Result(ModelMessage, String) { + let parameters = + parameters + |> option.lazy_unwrap(fn() { + model.new_send_dice_parameters(model.Stringed(ctx.key)) + }) + + api.send_dice(ctx.config.api, parameters) +} + +/// Use this method to edit text and game messages. +/// On success, if the edited message is not an inline message, the edited Message is returned, otherwise True is returned. +/// +/// **Official reference:** https://core.telegram.org/bots/api#editmessagetext +pub fn edit_message_text( + ctx ctx: Context(session), + parameters parameters: EditMessageTextParameters, +) -> Result(EditMessageTextResult, String) { + api.edit_message_text(ctx.config.api, parameters) +} + +/// Use this method to forward messages of any kind. Service messages and messages with protected content can't be forwarded. +/// On success, the sent Message is returned. +/// +/// **Official reference:** https://core.telegram.org/bots/api#forwardmessage +pub fn forward_message( + ctx ctx: Context(session), + parameters parameters: ForwardMessageParameters, +) -> Result(ModelMessage, String) { + api.forward_message(ctx.config.api, parameters) +} + +/// Use this method to send answers to callback queries sent from inline keyboards. +/// The answer will be displayed to the user as a notification at the top of the chat screen or as an alert. +/// On success, _True_ is returned. +/// +/// **Official reference:** https://core.telegram.org/bots/api#answercallbackquery +pub fn answer_callback_query( + ctx ctx: Context(session), + parameters parameters: AnswerCallbackQueryParameters, +) -> Result(Bool, String) { + api.answer_callback_query(ctx.config.api, parameters) +} diff --git a/test/telega_test.gleam b/test/telega_test.gleam index 81c2236..8d9596f 100644 --- a/test/telega_test.gleam +++ b/test/telega_test.gleam @@ -5,7 +5,7 @@ import gleeunit import gleeunit/should import mockth import telega/api -import telega/config +import telega/internal/config pub fn main() { gleeunit.main() @@ -30,7 +30,7 @@ fn with_mocked_httpc(resp: Response(String), wrapped: fn() -> Nil) { pub fn set_webhook_test() { use <- with_mocked_httpc(Response(200, [], "{\"ok\": true, \"result\": true}")) - create_new_config() - |> api.set_webhook() + create_new_config().api + |> api.set_webhook("/test") |> should.equal(Ok(True)) }