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

Copy Markdown View Source

Pure chat-linking rules for the Telegram integration: which chats a getUpdates peek may link, how a capture merges into what is already linked, and what counts as a hand-entered chat id.

Split out of the LiveView because this is where the surprising parts live (a group that capture may find but never link on its own, ids whose sign carries meaning) and because a form event is a poor place to prove them.

Chat kinds

Telegram numbers private chats positively and group/supergroup chats negatively — a documented invariant this module leans on, since a stored chat_ids entry is just a string with no type alongside it.

Privacy mode

A bot added to a group only receives messages addressed to it (a / command, an @mention, a reply, or a service message) unless it is a group admin. So a group reaches capturable_chats/1 when someone runs /start@yourbot in that group — plain chatter never will.

Summary

Functions

Chats that may be linked from a getUpdates result, oldest first, one entry per chat.

Folds a getUpdates peek into everything the connection should store: the linked ids, which of them are new, the metadata for those ids — and the candidates: captured groups that were NOT linked, for the owner to confirm one by one. Candidates are returned, never stored.

Whether a stored id denotes a group/supergroup.

What a stored id denotes. A stored chat_ids entry is a bare string, so kind is read back from its shape: Telegram signs group ids negative, and a channel is linked by its @handle.

Folds captured chats into the already-linked ids, returning {linked_ids, newly_added_ids}.

Normalizes a hand-entered chat id — a numeric id (negative for groups) or an @channelusername, both of which sendMessage accepts.

Metadata for the given ids only — everything else is dropped.

Functions

capturable_chats(updates)

@spec capturable_chats([map()]) :: [map()]

Chats that may be linked from a getUpdates result, oldest first, one entry per chat.

Keeps the chat's type and title so a caller can name a linked chat rather than show a bare id.

capture(mode, existing, existing_meta, chats)

@spec capture(String.t(), [String.t()], map(), [map()]) :: %{
  ids: [String.t()],
  added: [String.t()],
  meta: map(),
  candidates: [map()],
  ambiguous?: boolean()
}

Folds a getUpdates peek into everything the connection should store: the linked ids, which of them are new, the metadata for those ids — and the candidates: captured groups that were NOT linked, for the owner to confirm one by one. Candidates are returned, never stored.

ambiguous? is true when "single" refused to link because several private chats messaged the bot. Those chats are NOT candidates: offering strangers' private chats would show the owner who else talks to the bot, and picking one would still be a guess.

Metadata is kept for LINKED chats only, and pruned to them. Recording a chat the lock just refused would turn the connection's data into a log of everyone who has ever messaged the bot.

group_id?(id)

@spec group_id?(String.t()) :: boolean()

Whether a stored id denotes a group/supergroup.

kind(arg1)

@spec kind(String.t()) :: :group | :channel | :private

What a stored id denotes. A stored chat_ids entry is a bare string, so kind is read back from its shape: Telegram signs group ids negative, and a channel is linked by its @handle.

merge(mode, existing, chats)

@spec merge(String.t(), [String.t()], [map()]) :: {[String.t()], [String.t()]}

Folds captured chats into the already-linked ids, returning {linked_ids, newly_added_ids}.

"single" locks ONE private chat, and only when it is the ONLY private chat in the peek and no private chat is linked yet — a stranger who messaged the bot right after the owner cannot displace it. With several private chats in the peek nothing is linked: message order says nothing about which of them belongs to the person pressing Test, and taking the most recent bound whoever wrote last. capture/4 reports that case as ambiguous?. A group is never auto-linked in this mode: a bot's username is public and anyone may add it to a group of their own and run /start@bot there, so "someone ran the command in it" proves nothing about who that someone is. Captured groups come back from capture/4 as candidates for the connection's owner to confirm.

"multi" unions everything captured. The empty newly_added_ids is what lets a caller tell "nothing new was found" from "linked a chat" — the old code could not, and reported success either way.

Both modes govern AUTO-CAPTURE only. Linking a chat by id is a deliberate act by the connection's owner, so it is not capped here: "single" means "capture cannot quietly add a second private chat", not "this connection can only ever reach one chat".

normalize_chat_id(value)

@spec normalize_chat_id(String.t()) :: {:ok, String.t()} | :error

Normalizes a hand-entered chat id — a numeric id (negative for groups) or an @channelusername, both of which sendMessage accepts.

Hand entry exists because capture only reaches chats whose update is still in Telegram's ~24h queue; an id you already know should not need that window.

prune_meta(meta, ids)

@spec prune_meta(map(), [String.t()]) :: map()

Metadata for the given ids only — everything else is dropped.