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
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.
@spec capture(String.t(), [String.t()], map(), [map()]) :: %{ ids: [String.t()], added: [String.t()], meta: map(), candidates: [map()] }
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.
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.
Whether a stored id denotes a group/supergroup.
@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.
Folds captured chats into the already-linked ids, returning
{linked_ids, newly_added_ids}.
"single" locks ONE private chat: the most recent one, and only while no
private chat is linked yet — a stranger who messaged the bot right after
the owner cannot displace it. 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".
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.
Metadata for the given ids only — everything else is dropped.