Poll Telegram API until response

I am currently stuck with an implementation of a function that has to poll the Telegram API periodically and wait for a response from this. I guess there is some async / await magic that will help me to solve this problem in an elegant way. Maybe someone can jump in an give me a hint :wink:

Other suggestions for improvement are also very welcome :slight_smile:

defmodule TelegramHelper do
  @moduledoc """
  Documentation for TelegramHelper.
  """

  import Nadia

  @chat_id 123456

  @spec get_sms_verification_code() :: any()
  def get_sms_verification_code do
    # telegram api will return some old messages from time to time
    # so we have to check if there are old message and get the update_id to poll later only for newer messages
    update_id = 0
    {:ok, result} = get_updates
    if length(result) > 0 do
      update_id = List.last(result).update_id + 1
    end

    send_message(@chat_id, "Please send the Auth Code")
    get_reply(update_id)
  end

  defp get_reply(update_id) do
    Process.sleep(1000)
    {:ok, result} = get_updates(%{:update_id => update_id})
    if length(result) == 0 do
      # this should stop after a timeout of e.g. 5 Minutes
      # and is obviously the wrong way of doing it
      get_reply(update_id)
    end
    # this is only for reference on what i want to return if i get back a new message from telegram
    List.first(result).message.text
  end
end

:wave:

Have you considered using webhooks?

yep but i think it wonā€™t help me very muchā€¦

I am using this in some browser automation where i will forward a two factor authentication code from sms to telegram to the login process handlingā€¦ so i will end up with something like that:

Fill in email + password ā†’ click button to login ā†’ click button to send the sms ā†’ wait for sms code

Where ā€œwait for sms codeā€ will most likely be something similar to what I am doing now with the only difference that the waiting loop will be aborted by the webhook and not by my polling logic

Additionally I need some kind of webapp to handle the webhook (including access from the outside) which is not necessary if i use the polling method

Ah, I seem to have misunderstood the question then ā€¦

Iā€™ve never used telegramā€™s polling api, but I guess the same principles as with webhooks would apply ā€“ in general, I abstract the polling / webhooks into a separate process which acts as a ā€œtelegram message producerā€ and allows other processes to subscribe to it, I then have another processes for it as consumers which in their turn output ā€œactionsā€ for a ā€œtelegram broadcasterā€ process ā€¦ (The last one acting as a queue because telegram doesnā€™t allow sending more than 30 messages per second).

In your case however, it seems that this is not necessary. So maybe you can have a single process that would keep a map of auth codes as its state and match against them for every incoming message:

defmodule Auther do
  @moduledoc """
  Polls for new messages from telegram and compares them with auth codes
  """
  use GenServer

  defstruct :last_update_id, :auth_codes

  @doc false
  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: opts[:name])
  end

  @doc false
  def init(opts) do
    send(self(), :poll)
    last_update_id = opts[:update_id] || 0
    {:ok, %__MODULE__{last_update_id: last_update_id, auth_codes: %{}}}
  end

  @doc false
  def handle_info(:poll, state) do
    case get_updates(last_update_id) do
      [] ->
        Process.send_after(self(), :poll, 5000)
        {:noreply, state}
      new_messages ->
        {:noreply, handle_new_messages(state, new_messages)}
    end
  end

  defp handle_new_messages(state, new_messages) do
    [%{id: last_update_id} | _rest] = :lists.reverse(new_messages)
    auth_codes = # iterate over messages, get the ones that provide new auth codes or what have you
    %{state | last_update_id: last_update_id, auth_codes: auth_codes}
  end

  defp get_updates(last_update_id) do
    # gets updates since the last_update_id
  end
end

The map could look like %{chat_id => auth_code}, so when you receive a message for chat_id, you compare the contents of that message with auth_code and then do something about it ā€¦

I believe you should be returning a tuple from handle_info/2:

  def handle_info(:poll, state) do
    case get_updates(last_update_id) do
      [] ->
        Process.send_after(self(), :poll, 5000)
        {:noreply, state}
      new_messages ->
        state = handle_new_messages(state, new_messages)
        {:noreply, state}
    end
  end
2 Likes

Nice, that looks like it goes into the right direction, thank you very much for the help :slight_smile:

Quick additional Info: that programm is only used by me with a single chat id so I only need the last/one auth code and not a map of codesā€¦ but I can adapt that to my needsā€¦

Would you mind showing me an example on how I would integrate that Auther Module into my procedural browser automation code? E.g. if i have this pseudo code for my browser automation:

fill_email_password("mail", "password")
click_auth_button
# code to wait for telegram code which also handles timeout
# on timeout: log error and exit
fill_auth_code(code_from_telegram)

Note that 'mail' != "mail" in elixir, since the former is a charlist and the latter is a binary.

Would you mind showing me an example on how I would integrate that Auther Module into my procedural browser automation code?

Sure, maybe a blocking call would work

fill_email_password("mail", "password")
click_auth_button
auth_code = get_code_from_telegram()
fill_auth_code(code_from_telegram)

This can be achieved with Task.await, for example, where youā€™d call the Auther module to add a chat id to its internal state to wait an auth code for.

good catchā€¦ edited my post to fix thisā€¦

And exactly thats the point where I am stuck. I couldnā€™t wrap my head around that. How would this look? Even after reading the Docs about Task i have no clue on how to build a looping async function :wink:

I donā€™t think you need a looping async functions ā€¦ Iā€™d do something like

# in Auther

def wait_for_chat(server , chat_id, timeout \\ 5000) do
  GenServer.call(server, {:wait_for_chat, chat_id}, timeout)
end

@doc false
def handle_call({:wait_for_chat, chat_id}, from, state) do
  # note that `Map.put` would override any previous "subscription" (from)
  # if this is a problem, store `from`s in a list
  # auth_codes = %{chat_id => [from1, from2, from3, ...]}
  {:noreply, %{state | auth_codes: Map.put(state.auth_codes, chat_id, from)}}
end

defp handle_new_messages(state, new_messages) do
  # go over new messages and check if there are any auth codes from
  # the chat ids in state.auth_codes, if there are, reply to 
  # the client process in the corresponding to that chat_id's `from`
  auth_codes = Enum.reduce(new_messages, state.auth_codes, fn %{"chat" => %{"id" => chat_id}, "text" => message}, auth_codes ->
    with true <- auth_code?(message),
         {from, auth_codes} when not is_nil(from) <- Map.pop(auth_codes, chat_id) do
      GenServer.reply(from, message)
      auth_codes
    else
      _ -> auth_codes
    end
  end)
  %{state | auth_codes: auth_codes}
end

Then in your browser automation program you can do

fill_email_password("mail", "password")
click_auth_button
auth_code = Auther.wait_for_chat(auther_pid, chat_id, :infinity) # or just a large number for timeout
fill_auth_code(code_from_telegram)

state.auth_codes should probably be renamed to state.awaiting_auth_codes or awaiting_chats_auth_code something like that. It was named incorrectly the first time since I misunderstood your problem again, it seems.