PhoenixKit.Integrations.Telegram.Linking (phoenix_kit v2.62.2)

Copy Markdown View Source

Links a Telegram chat to a user account through the site bot, with a one-time deep link instead of guessing who wrote to the bot last.

Flow

  1. create_token/2 mints a token for a signed-in user and a site bot connection. deep_link/2 turns it into https://t.me/<bot>?start=<token>.
  2. The user opens the link and presses Start. Telegram sends the bot /start <token>.
  3. poll/1 reads the bot's pending updates, and every /start <token> from a private chat links THAT chat to the token's owner. The sender is never asked who they are — the token already says.

The token is 48 random bytes in base64url (64 characters, the most Telegram allows in a start parameter). Only its SHA-256 hash is stored, in phoenix_kit_users_tokens under the "telegram_link" context with the connection uuid in sent_to. It lives 15 minutes and is spent by the delete that reads it, so two racing polls cannot both use it.

Reading the updates

There is no webhook and no always-on poller: poll/1 runs when someone is waiting (the profile page polls while a link is pending, and has a "I sent it" button). Telegram keeps the offset, so nothing is stored here.

The database work of a batch — spending the tokens and recording the links — is one short transaction under a Postgres advisory lock (so two nodes do not link the same batch together). Everything that talks to Telegram happens outside it: the read before, and after the commit the confirmation of the offset and the replies. If the confirmation fails the batch is read again; that is harmless, because a /start whose chat is already linked to this bot is passed over without a word instead of answered "expired".

The stored link is custom_fields["notification_channel:telegram"] ("site_chat_id", "site_connection_uuid", "linked_at"), next to the routing the notification channel already keeps there.

Summary

Functions

Spends a token: {:ok, %{user_uuid:, connection_uuid:}} the first time it is presented while valid for bot connection_uuid, :error otherwise — junk, expired, spent, of another kind, or minted for another bot (which leaves it unspent).

Mints a link token for user on site bot connection_uuid, replacing the user's earlier one.

The t.me link that opens the bot with token as its start parameter.

Records the link on the user. The FIRST link also switches on the notification types the user already receives in the app; later links keep whatever the user chose. A channel the delivery worker had switched off because the old chat was dead is switched back on.

The user's site-bot link as %{chat_id:, connection_uuid:, linked_at:}, or nil.

Whether user has a link for the bot connection_uuid that has been minted and not yet used or expired.

Reads the site bot's pending updates and links every chat that sent a valid /start <token>. Returns {:ok, %{linked: n}}, :busy when another node is linking this bot's batch right now, or {:error, reason} when Telegram could not be read (nothing is lost: the updates stay queued).

How long a link stays good, in minutes.

Removes the site-bot link. The routing choices stay: linking again should not make the user pick them all over.

Functions

consume_token(token, connection_uuid)

@spec consume_token(term(), String.t()) ::
  {:ok, %{user_uuid: String.t(), connection_uuid: String.t()}} | :error

Spends a token: {:ok, %{user_uuid:, connection_uuid:}} the first time it is presented while valid for bot connection_uuid, :error otherwise — junk, expired, spent, of another kind, or minted for another bot (which leaves it unspent).

create_token(user, connection_uuid)

@spec create_token(map(), String.t()) :: {:ok, String.t()} | {:error, term()}

Mints a link token for user on site bot connection_uuid, replacing the user's earlier one.

deep_link(bot_username, token)

@spec deep_link(String.t(), String.t()) :: String.t()

The t.me link that opens the bot with token as its start parameter.

linked(user)

@spec linked(map()) ::
  %{
    chat_id: String.t(),
    connection_uuid: String.t(),
    linked_at: String.t() | nil
  }
  | nil

The user's site-bot link as %{chat_id:, connection_uuid:, linked_at:}, or nil.

pending?(map, connection_uuid)

@spec pending?(map(), String.t()) :: boolean()

Whether user has a link for the bot connection_uuid that has been minted and not yet used or expired.

poll(connection_uuid)

@spec poll(String.t()) ::
  {:ok, %{linked: non_neg_integer()}} | :busy | {:error, term()}

Reads the site bot's pending updates and links every chat that sent a valid /start <token>. Returns {:ok, %{linked: n}}, :busy when another node is linking this bot's batch right now, or {:error, reason} when Telegram could not be read (nothing is lost: the updates stay queued).

ttl_minutes()

@spec ttl_minutes() :: pos_integer()

How long a link stays good, in minutes.

unlink(map)

@spec unlink(map()) :: :ok | {:error, term()}

Removes the site-bot link. The routing choices stay: linking again should not make the user pick them all over.