diff --git a/lib/bot/chat_bot/chat/chat.ex b/lib/bot/chat_bot/chat/chat.ex new file mode 100644 index 0000000..c319dd2 --- /dev/null +++ b/lib/bot/chat_bot/chat/chat.ex @@ -0,0 +1,15 @@ +defmodule Telegram.ChatBot.Chat do + @moduledoc """ + A struct that represents a chat extracted from a Telegram update. + Currently the only required field is `id`, any other data you may want to pass to + `c:Telegram.ChatBot.init/1` should be included under the `metadata` field. + """ + + @type t() :: %__MODULE__{ + id: String.t(), + metadata: map() | nil + } + + @enforce_keys [:id] + defstruct [:metadata] ++ @enforce_keys +end diff --git a/lib/bot/chat_bot/chat/session/server.ex b/lib/bot/chat_bot/chat/session/server.ex index f0a99b9..ddb535d 100644 --- a/lib/bot/chat_bot/chat/session/server.ex +++ b/lib/bot/chat_bot/chat/session/server.ex @@ -3,7 +3,7 @@ defmodule Telegram.Bot.ChatBot.Chat.Session.Server do use GenServer, restart: :transient require Logger - alias Telegram.Bot.{ChatBot.Chat, Utils} + alias Telegram.Bot.ChatBot.Chat alias Telegram.{ChatBot, Types} defmodule State do @@ -13,21 +13,21 @@ defmodule Telegram.Bot.ChatBot.Chat.Session.Server do defstruct @enforce_keys end - @spec start_link({ChatBot.t(), Types.token(), ChatBot.chat()}) :: GenServer.on_start() - def start_link({chatbot_behaviour, token, %{"id" => chat_id} = chat}) do + @spec start_link({ChatBot.t(), Types.token(), ChatBot.Chat.t(), nil | term()}) :: GenServer.on_start() + def start_link({chatbot_behaviour, token, chat, bot_state}) do GenServer.start_link( __MODULE__, - {chatbot_behaviour, token, chat}, - name: Chat.Registry.via(token, chat_id) + {chatbot_behaviour, token, chat, bot_state}, + name: Chat.Registry.via(token, chat.id) ) end @spec resume(ChatBot.t(), Types.token(), String.t(), term()) :: :ok | {:error, :already_started | :max_children} - def resume(chatbot_behaviour, token, chat_id, state) do - chat = %{"resume" => :resume, "id" => chat_id, "state" => state} + def resume(chatbot_behaviour, token, chat_id, bot_state) do + chat = %Telegram.ChatBot.Chat{id: chat_id} with {:lookup, {:error, :not_found}} <- {:lookup, Chat.Registry.lookup(token, chat_id)}, - {:start, {:ok, _server}} <- {:start, start_chat_session_server(chatbot_behaviour, token, chat)} do + {:start, {:ok, _server}} <- {:start, start_chat_session_server(chatbot_behaviour, token, chat, bot_state)} do :ok else # coveralls-ignore-start @@ -42,12 +42,12 @@ defmodule Telegram.Bot.ChatBot.Chat.Session.Server do @spec handle_update(ChatBot.t(), Types.token(), Types.update()) :: :ok def handle_update(chatbot_behaviour, token, update) do - with {:get_chat, {:ok, chat}} <- {:get_chat, Utils.get_chat(update)}, + with {:get_chat, {:ok, chat}} <- {:get_chat, get_chat(chatbot_behaviour, update)}, {:get_chat_session_server, {:ok, server}} <- {:get_chat_session_server, get_chat_session_server(chatbot_behaviour, token, chat)} do GenServer.cast(server, {:handle_update, update}) else - {:get_chat, nil} -> + {:get_chat, :ignore} -> Logger.info("Dropped update without chat #{inspect(update)}", bot: chatbot_behaviour, token: token) {:get_chat_session_server, {:error, :max_children}} -> @@ -58,10 +58,10 @@ defmodule Telegram.Bot.ChatBot.Chat.Session.Server do end @impl GenServer - def init({chatbot_behaviour, token, %{"resume" => :resume, "id" => chat_id, "state" => bot_state}}) do - Logger.metadata(bot: chatbot_behaviour, chat_id: chat_id) + def init({chatbot_behaviour, token, %Telegram.ChatBot.Chat{} = chat, bot_state}) when bot_state != nil do + Logger.metadata(bot: chatbot_behaviour, chat_id: chat.id) - state = %State{token: token, chatbot_behaviour: chatbot_behaviour, chat_id: chat_id, bot_state: bot_state} + state = %State{token: token, chatbot_behaviour: chatbot_behaviour, chat_id: chat.id, bot_state: bot_state} case chatbot_behaviour.handle_resume(bot_state) do {:ok, bot_state} -> @@ -74,10 +74,10 @@ defmodule Telegram.Bot.ChatBot.Chat.Session.Server do end end - def init({chatbot_behaviour, token, %{"id" => chat_id} = chat}) do - Logger.metadata(bot: chatbot_behaviour, chat_id: chat_id) + def init({chatbot_behaviour, token, %Telegram.ChatBot.Chat{} = chat, nil}) do + Logger.metadata(bot: chatbot_behaviour, chat_id: chat.id) - state = %State{token: token, chatbot_behaviour: chatbot_behaviour, chat_id: chat_id, bot_state: nil} + state = %State{token: token, chatbot_behaviour: chatbot_behaviour, chat_id: chat.id, bot_state: nil} chatbot_behaviour.init(chat) |> case do @@ -92,6 +92,7 @@ defmodule Telegram.Bot.ChatBot.Chat.Session.Server do @impl GenServer def handle_cast({:handle_update, update}, %State{} = state) do res = state.chatbot_behaviour.handle_update(update, state.token, state.bot_state) + handle_callback_result(res, state) end @@ -109,8 +110,17 @@ defmodule Telegram.Bot.ChatBot.Chat.Session.Server do handle_callback_result(res, state) end - defp get_chat_session_server(chatbot_behaviour, token, %{"id" => chat_id} = chat) do - Chat.Registry.lookup(token, chat_id) + defp get_chat(chatbot_behaviour, update) do + [update_type] = + update + |> Map.drop(["update_id"]) + |> Map.keys() + + chatbot_behaviour.get_chat(update_type, Map.get(update, update_type)) + end + + defp get_chat_session_server(chatbot_behaviour, token, %Telegram.ChatBot.Chat{} = chat) do + Chat.Registry.lookup(token, chat.id) |> case do {:ok, _server} = ok -> ok @@ -120,8 +130,8 @@ defmodule Telegram.Bot.ChatBot.Chat.Session.Server do end end - defp start_chat_session_server(chatbot_behaviour, token, chat) do - child_spec = {__MODULE__, {chatbot_behaviour, token, chat}} + defp start_chat_session_server(chatbot_behaviour, token, %Telegram.ChatBot.Chat{} = chat, bot_state \\ nil) do + child_spec = {__MODULE__, {chatbot_behaviour, token, chat, bot_state}} Chat.Session.Supervisor.start_child(child_spec, token) |> case do diff --git a/lib/bot/utils.ex b/lib/bot/utils.ex index caea3b1..1d064ba 100644 --- a/lib/bot/utils.ex +++ b/lib/bot/utils.ex @@ -52,21 +52,4 @@ defmodule Telegram.Bot.Utils do nil end end - - @doc """ - Get the "chat" field in an Update object, if present - """ - @spec get_chat(Types.update()) :: {:ok, map()} | nil - def get_chat(update) do - Enum.find_value(update, fn - {_update_type, %{"chat" => %{"id" => _} = chat}} -> - {:ok, chat} - - {_update_type, %{"message" => %{"chat" => %{"id" => _} = chat}} = _chat} -> - {:ok, chat} - - _ -> - nil - end) - end end diff --git a/lib/chat_bot.ex b/lib/chat_bot.ex index c4a11e1..8ea9a9b 100644 --- a/lib/chat_bot.ex +++ b/lib/chat_bot.ex @@ -2,15 +2,20 @@ defmodule Telegram.ChatBot do @moduledoc ~S""" Telegram Chat Bot behaviour. - The difference with `Telegram.Bot` behaviour is that the `Telegram.ChatBot` is "statefull" per chat_id, - (see `chat_state` argument). + The `Telegram.ChatBot` module provides a stateful chatbot mechanism that manages bot instances + on a per-chat basis (`chat_id`). Unlike the `Telegram.Bot` behavior, which is stateless, + each conversation in `Telegram.ChatBot` is tied to a unique `chat_state`. - Given that every "conversation" is associated with a long running process is up to you to consider - a session timeout in your bot state machine design. If you don't you will saturate the max_bot_concurrency - capacity and then your bot won't accept any new conversation. - For this you can leverage the underlying gen_server timeout including the timeout in the return value - of the `c:init/1` or `c:handle_update/3` callbacks or, if you need a more complex behaviour, via explicit - timers in you bot. + The `c:get_chat/2` callback is responsible for routing each incoming update to the correct + chat session by returning the chat's identifier. If the chat is not yet recognized, + a new bot instance will automatically be created for that chat. + + Since each conversation is handled by a long-running process, it's crucial to manage session + timeouts carefully. Without implementing timeouts, your bot may hit the `max_bot_concurrency` limit, + preventing it from handling new conversations. To prevent this, you can utilize the underlying + `:gen_server` timeout mechanism by specifying timeouts in the return values of the `c:init/1` or + `c:handle_update/3` callbacks. Alternatively, for more complex scenarios, you can manage explicit + timers in your bot's logic. ## Example @@ -18,16 +23,19 @@ defmodule Telegram.ChatBot do defmodule HelloBot do use Telegram.ChatBot + # Session timeout set to 60 seconds @session_ttl 60 * 1_000 @impl Telegram.ChatBot def init(_chat) do + # Initialize state with a message counter set to 0 count_state = 0 {:ok, count_state, @session_ttl} end @impl Telegram.ChatBot def handle_update(%{"message" => %{"chat" => %{"id" => chat_id}}}, token, count_state) do + # Increment the message count count_state = count_state + 1 Telegram.Api.request(token, "sendMessage", @@ -39,20 +47,21 @@ defmodule Telegram.ChatBot do end def handle_update(update, _token, count_state) do - # ignore unknown updates + # Ignore unknown updates and maintain the current state {:ok, count_state, @session_ttl} end @impl Telegram.ChatBot def handle_info(msg, _token, _chat_id, count_state) do - # direct message processing + # Handle direct erlang messages, if needed {:ok, count_state} end @impl Telegram.ChatBot def handle_timeout(token, chat_id, count_state) do + # Send a "goodbye" message upon session timeout Telegram.Api.request(token, "sendMessage", chat_id: chat_id, text: "See you!" @@ -75,38 +84,81 @@ defmodule Telegram.ChatBot do @type chat_state :: any() @doc """ - Invoked once when the chat starts. - Return the initial chat_state. + Invoked when a chat session is first initialized. Returns the initial `chat_state` for the session. + + ### Parameters: + + - `chat`: the `t:Telegram.ChatBot.Chat.t/0` struct returned by `c:get_chat/2`. + + ### Return values + + - `{:ok, initial_state}`: initializes the session with the provided `initial_state`. + - `{:ok, initial_state, timeout}`: initializes the session with the provided `initial_state`, and sets a timeout for the session. + + The `timeout` can be used to schedule actions after a certain period of inactivity. """ - @callback init(chat :: chat()) :: + @callback init(chat :: Telegram.ChatBot.Chat.t()) :: {:ok, initial_state :: chat_state()} | {:ok, initial_state :: chat_state(), timeout :: timeout()} @doc """ - On resume callback. + Invoked when a chat session is resumed. - This callback is optional. - A default implementation is injected with "use Telegram.ChatBot", it just returns the received state. + If implemented, this function allows custom logic when resuming a session, for example, + updating the state or setting a new timeout. - Note: a resume/3 function is available on every ChatBot, `MyChatBot.resume(token, chat_id, state)`. + Note: you can manually resume a session by calling `MyChatBot.resume(token, chat_id, state)`. + + ### Return values + + - `{:ok, next_chat_state}`: resumes the session with the provided `next_chat_state`. + - `{:ok, next_chat_state, timeout}`: resumes the session with the `next_chat_state` and sets a new `timeout`. + + The `timeout` can be used to schedule actions after a specific period of inactivity. """ @callback handle_resume(chat_state :: chat_state()) :: {:ok, next_chat_state :: chat_state()} | {:ok, next_chat_state :: chat_state(), timeout :: timeout()} @doc """ - Receives the telegram update event and the "current" chat_state. - Return the "updated" chat_state. + Handles incoming Telegram update events and processes them based on the current `chat_state`. + + ### Parameters: + + - `update`: the incoming Telegram [update](https://core.telegram.org/bots/api#update) event (e.g., a message, an inline query). + - `token`: the bot's authentication token, used to make API requests. + - `chat_state`: the current state of the chat session. + + ### Return values: + + - `{:ok, next_chat_state}`: updates the chat session with the new `next_chat_state`. + - `{:ok, next_chat_state, timeout}`: updates the `next_chat_state` and sets a new `timeout` for the session. + - `{:stop, next_chat_state}`: terminates the chat session and returns the final `next_chat_state`. + + The `timeout` option can be used to define how long the bot will wait for the next event before triggering a timeout. """ @callback handle_update(update :: Types.update(), token :: Types.token(), chat_state :: chat_state()) :: {:ok, next_chat_state :: chat_state()} | {:ok, next_chat_state :: chat_state(), timeout :: timeout()} | {:stop, next_chat_state :: chat_state()} + @doc """ - On timeout callback. + Callback invoked when a session times out. + + ### Parameters + + - `token`: the bot's authentication token, used for making API requests. + - `chat_id`: the ID of the chat where the session timed out. + - `chat_state`: the current state of the chat session at the time of the timeout. - This callback is optional. - A default implementation is injected with "use Telegram.ChatBot", it just stops the bot. + ### Return Values: + + - `{:ok, next_chat_state}`: keeps the session alive with an updated `next_chat_state`. + - `{:ok, next_chat_state, timeout}`: updates the `next_chat_state` and sets a new `timeout`. + - `{:stop, next_chat_state}`: terminates the session and finalizes the `chat_state`. + + This callback is **optional**. + If not implemented, the bot will stops when a timeout occurs. """ @callback handle_timeout(token :: Types.token(), chat_id :: String.t(), chat_state :: chat_state()) :: {:ok, next_chat_state :: chat_state()} @@ -114,19 +166,68 @@ defmodule Telegram.ChatBot do | {:stop, next_chat_state :: chat_state()} @doc """ - On handle_info callback. + Invoked to handle arbitrary erlang messages (e.g., scheduled events or direct messages). + + This callback can be used for: + - Scheduled Events: handle messages triggered by Process.send/3 or Process.send_after/4. + - Direct Interactions: respond to direct messages sent to a specific chat session retrieved via `lookup/2`. + + ### Parameters: + + - `msg`: the message received. + - `token`: the bot's authentication token, used to make API requests. + - `chat_id`: the ID of the chat session associated with the message. + - `chat_state`: the current state of the chat session. - Can be used to implement bots that act on scheduled events (using `Process.send/3` and `Process.send_after/4`) or to interact via direct message to a a specific chat session (using `lookup/2`). + ### Return values: - This callback is optional. - If one is not implemented, the received message will be logged. + - `{:ok, next_chat_state}`: updates the session with a new `next_chat_state`. + - `{:ok, next_chat_state, timeout}`: updates the `next_chat_state` and sets a new `timeout`. + - `{:stop, next_chat_state}`: terminates the session and returns the final `chat_state`. + + This callback is **optional**. + If not implemented, any received message will be logged by default. """ @callback handle_info(msg :: any(), token :: Types.token(), chat_id :: String.t(), chat_state :: chat_state()) :: {:ok, next_chat_state :: chat_state()} | {:ok, next_chat_state :: chat_state(), timeout :: timeout()} | {:stop, next_chat_state :: chat_state()} - @optional_callbacks handle_resume: 1, handle_info: 4, handle_timeout: 3 + @doc """ + Allows a chatbot to customize how incoming updates are processed. + + This function receives an update and either returns the unique chat identifier + associated with it or instructs the bot to ignore the update. + + ### Parameters: + + - `update_type`: is a string representing the type of update received. For example: + - `message`: For new messages. + - `edited_message`: For edited messages. + - `inline_query`: For inline queries. + - `update`: the update object received, containing the data associated with the `update_type`. + The object structure depends on the type of update: + - For `message` and `edited_message` updates, the object is of type [`Message`](https://core.telegram.org/bots/api#message), + which contains fields such as text, sender, and chat. + - For `inline_query` updates, the object is of type [`InlineQuery`](https://core.telegram.org/bots/api#inlinequery), containing fields like query and from. + + Refer to the official Telegram Bot API [documentation](https://core.telegram.org/bots/api#update) + for a complete list of update types. + + ### Return values: + + - Returning `{:ok, %Telegram.ChatBot.Chat{id: id, metadata: %{}}}` will trigger + the bot to spin up a new instance, which will manage the update as a full chat session. + The instance will be uniquely identified by the return `id` and + `c:init/1` will be called with the returned `t:Telegram.ChatBot.Chat.t/0` struct. + - Returning `:ignore` will cause the update to be disregarded entirely. + + This callback is **optional**. + If not implemented, the bot will dispatch updates of type [`Message`](https://core.telegram.org/bots/api#message). + """ + @callback get_chat(update_type :: String.t(), update :: Types.update()) :: {:ok, Telegram.ChatBot.Chat.t()} | :ignore + + @optional_callbacks get_chat: 2, handle_resume: 1, handle_info: 4, handle_timeout: 3 @doc false defmacro __using__(_use_opts) do @@ -136,6 +237,15 @@ defmodule Telegram.ChatBot do require Logger + @impl Telegram.ChatBot + def get_chat(_, %{"chat" => %{"id" => chat_id} = chat}), + do: {:ok, %Telegram.ChatBot.Chat{id: chat_id, metadata: %{chat: chat}}} + + def get_chat(_, %{"message" => %{"chat" => %{"id" => chat_id} = chat}}), + do: {:ok, %Telegram.ChatBot.Chat{id: chat_id, metadata: %{chat: chat}}} + + def get_chat(_, _), do: :ignore + @impl Telegram.ChatBot def handle_resume(chat_state) do {:ok, chat_state} @@ -153,7 +263,7 @@ defmodule Telegram.ChatBot do {:stop, chat_state} end - defoverridable handle_resume: 1, handle_info: 4, handle_timeout: 3 + defoverridable get_chat: 2, handle_resume: 1, handle_info: 4, handle_timeout: 3 @spec child_spec(Types.bot_opts()) :: Supervisor.child_spec() def child_spec(bot_opts) do @@ -173,10 +283,18 @@ defmodule Telegram.ChatBot do end @doc """ - Resume a ChatBot. - A chat session for `chat_id` is restored at the previous `state`. + Resumes a `Telegram.ChatBot` sessions. + + Restores the chat session for the given `chat_id` using the previously saved `state`. - It's caller responsability to pass the same token used to start this bot. + Note: it is the caller's responsibility to provide the same `token` that was used + when the bot was initially started. + + ### Return values: + + - `:ok`: The chat session was successfully resumed. + - `{:error, :already_started}`: the chat session is already active and cannot be resumed. + - `{:error, :max_children}`: the bot has reached its maximum concurrency limit and cannot accept new sessions. """ @spec resume(Types.token(), String.t(), term()) :: :ok | {:error, :already_started | :max_children} def resume(token, chat_id, state) do @@ -186,10 +304,17 @@ defmodule Telegram.ChatBot do end @doc """ - Lookup the pid of a specific chat session. + Retrieves the process ID (`pid`) of a specific chat session. + + This function allows you to look up the active process managing a particular chat session. + + Note: it is the user's responsibility to maintain and manage the mapping between + the custom session identifier (specific to the business logic) and the Telegram `chat_id`. + + ### Return values: - It is up to the user to define and keep a mapping between - the business logic specific session identifier and the telegram chat_id. + - `{:ok, pid}`: successfully found the pid of the chat session. + - `{:error, :not_found}`: no active session was found for the provided `chat_id`. """ @spec lookup(Types.token(), String.t()) :: {:error, :not_found} | {:ok, pid} def lookup(token, chat_id) do diff --git a/test/bot_util_test.exs b/test/bot_util_test.exs index 147a4ed..5c05dff 100644 --- a/test/bot_util_test.exs +++ b/test/bot_util_test.exs @@ -20,12 +20,4 @@ defmodule Test.Telegram.Bot.Utils do assert {:ok, datetime} == Utils.get_sent_date(%{"callback_query" => %{"message" => %{"date" => DateTime.to_unix(datetime, :second)}}}) end - - test "get_chat" do - assert nil == Utils.get_chat(%{}) - assert {:ok, %{"id" => "123"}} == Utils.get_chat(%{"message" => %{"chat" => %{"id" => "123"}}}) - - assert {:ok, %{"id" => "123"}} == - Utils.get_chat(%{"callback_query" => %{"message" => %{"chat" => %{"id" => "123"}}}}) - end end diff --git a/test/chat_bot_get_chat_test.exs b/test/chat_bot_get_chat_test.exs new file mode 100644 index 0000000..4fda8fd --- /dev/null +++ b/test/chat_bot_get_chat_test.exs @@ -0,0 +1,97 @@ +defmodule Test.Telegram.ChatBotGetChat do + use ExUnit.Case, async: false + + alias Test.Webhook + import Test.Utils.{Const, Mock} + + setup_all do + Test.Utils.Mock.tesla_mock_global_async() + :ok + end + + setup [:setup_test_bot] + + test "updates" do + url_test_response = tg_url(tg_token(), "testResponse") + chat_id = "chat_id_1234" + + 1..3 + |> Enum.each(fn idx -> + assert {:ok, _} = + Webhook.update(tg_token(), %{ + "update_id" => idx, + "message" => %{"text" => "/count", "chat" => %{"id" => chat_id}} + }) + + assert :ok == + tesla_mock_expect_request( + %{method: :post, url: ^url_test_response}, + fn %{body: body} -> + body = Jason.decode!(body) + assert body["chat_id"] == chat_id + assert body["text"] == "#{idx}" + + response = %{"ok" => true, "result" => []} + Tesla.Mock.json(response, status: 200) + end, + false + ) + end) + end + + test "inline query" do + url_test_response = tg_url(tg_token(), "testResponse") + query_id_base = "chat_id_1234" + + 1..3 + |> Enum.each(fn idx -> + assert {:ok, _} = + Webhook.update(tg_token(), %{ + "update_id" => idx, + "inline_query" => %{ + "id" => "#{query_id_base}_#{idx}", + "query" => "some query", + "chat_type" => "private", + "offset" => "", + "from" => %{"id" => "user_id"} + } + }) + + assert :ok == + tesla_mock_expect_request( + %{method: :post, url: ^url_test_response}, + fn %{body: body} -> + body = Jason.decode!(body) + assert body["query_id"] == "#{query_id_base}_#{idx}" + assert body["query"] == "some query" + assert body["text"] == "1" + + response = %{"ok" => true, "result" => []} + Tesla.Mock.json(response, status: 200) + end, + false + ) + end) + end + + test "received update out of a chat - ie: chat_id not present" do + url_test_response = tg_url(tg_token(), "testResponse") + + assert {:ok, _} = + Webhook.update(tg_token(), %{ + "update_id" => 1, + "update_without_chat_if" => %{} + }) + + assert :ok == + tesla_mock_refute_request(%{method: :post, url: ^url_test_response}) + end + + defp setup_test_bot(_context) do + config = [set_webhook: false, host: "host.com"] + bots = [{Test.ChatBotGetChat, [token: tg_token(), max_bot_concurrency: 1]}] + start_supervised!({Telegram.Webhook, config: config, bots: bots}) + + :ok + end +end diff --git a/test/support/test_chatbot_get_chat.ex b/test/support/test_chatbot_get_chat.ex new file mode 100644 index 0000000..b21841f --- /dev/null +++ b/test/support/test_chatbot_get_chat.ex @@ -0,0 +1,49 @@ +defmodule Test.ChatBotGetChat do + @moduledoc false + + use Telegram.ChatBot + + @impl Telegram.ChatBot + def get_chat("inline_query", %{"id" => inline_chat_id}) do + {:ok, %Telegram.ChatBot.Chat{id: inline_chat_id}} + end + + def get_chat(_, %{"chat" => %{"id" => chat_id}}) do + {:ok, %Telegram.ChatBot.Chat{id: chat_id}} + end + + def get_chat(_, _) do + :ignore + end + + @impl Telegram.ChatBot + def init(_chat) do + count_state = 0 + {:ok, count_state} + end + + @impl Telegram.ChatBot + def handle_update(%{"inline_query" => %{"id" => query_id, "query" => query}}, token, count_state) do + count_state = count_state + 1 + + Telegram.Api.request(token, "testResponse", + query_id: query_id, + query: query, + text: "#{count_state}" + ) + + {:stop, count_state} + end + + @impl Telegram.ChatBot + def handle_update(%{"message" => %{"text" => "/count", "chat" => %{"id" => chat_id}}}, token, count_state) do + count_state = count_state + 1 + + Telegram.Api.request(token, "testResponse", + chat_id: chat_id, + text: "#{count_state}" + ) + + {:ok, count_state} + end +end