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

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()]
}

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.

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: 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".

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.