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
create_token/2mints a token for a signed-in user and a site bot connection.deep_link/2turns it intohttps://t.me/<bot>?start=<token>.- The user opens the link and presses Start. Telegram sends the bot
/start <token>. poll/1reads 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
@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).
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.
@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.
Whether user has a link for the bot connection_uuid that has been minted and not yet used or expired.
@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).
@spec ttl_minutes() :: pos_integer()
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.