Packages

phoenix_kit

1.7.218
1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit notifications channels telegram.ex
Raw

lib/phoenix_kit/notifications/channels/telegram.ex

defmodule PhoenixKit.Notifications.Channels.Telegram do
@moduledoc """
Telegram notification channel — delivers a user's notifications to their
personal Telegram bot connection(s).
There's nothing to configure on the notifications page: the channel
**auto-discovers** the recipient's Telegram integration connections and sends
to the chat(s) captured on them (during Test on the integration form). Each
connection carries a `mode`:
* `"single"` — one locked chat (the owner's) → the bot messages only them.
* `"multi"` — every chat that has started the bot → a broadcast.
So a connection stores `chat_ids` (one for single, many for multi) and the
channel sends to all of them. `configured?/2` is true once the recipient has a
connection with at least one captured chat. The bot token is resolved
owner-scoped to the recipient at send time — this module never handles it.
The `config` arg (the `notification_channel:telegram` blob) carries only
routing/cadence for this channel; connection + chat live on the integration.
"""
@behaviour PhoenixKit.Notifications.Channel
use Gettext, backend: PhoenixKitWeb.Gettext
require Logger
alias PhoenixKit.Integrations
alias PhoenixKit.Integrations.Telegram, as: Client
@impl true
def key, do: "telegram"
@impl true
def label, do: gettext("Telegram")
@impl true
def icon, do: "hero-paper-airplane"
@impl true
def configured?(user_uuid, _config), do: linked_targets(user_uuid) != []
@impl true
def deliver(envelope, _config) do
deliver_to(linked_targets(envelope.recipient_uuid), envelope)
end
@impl true
def validate_config(config) when is_map(config), do: {:ok, config}
# --- Discovery ------------------------------------------------------------
# `{connection_uuid, chat_id}` pairs for every chat captured on the user's
# Telegram connections. Single-mode connections contribute one; multi many.
defp linked_targets(user_uuid) when is_binary(user_uuid) do
"telegram"
|> Integrations.list_connections(owner: {:user, user_uuid})
|> Enum.flat_map(fn %{uuid: uuid, data: data} ->
Enum.map(chat_ids_of(data), &{uuid, &1})
end)
rescue
_ -> []
end
defp linked_targets(_), do: []
defp chat_ids_of(data) do
case data["chat_ids"] do
list when is_list(list) -> list |> Enum.map(&to_string/1) |> Enum.reject(&(&1 == ""))
_ -> []
end
end
# --- Delivery -------------------------------------------------------------
defp deliver_to([], _envelope), do: {:error, {:permanent, :not_configured}}
# A single target (single mode / one bot, one chat): propagate the error so a
# blocked chat soft-disables the channel.
defp deliver_to([{uuid, chat_id}], envelope) do
case send(uuid, chat_id, envelope) do
{:ok, _} -> :ok
{:error, reason} -> classify(reason)
end
end
# Several targets (multi mode / broadcast): best-effort — one blocked
# subscriber shouldn't disable the whole channel. Retry only if something was
# transiently unreachable (may re-send to already-delivered chats — Telegram
# has no idempotency key, so delivery is at-least-once).
defp deliver_to(targets, envelope) do
results = Enum.map(targets, fn {uuid, chat_id} -> send(uuid, chat_id, envelope) end)
cond do
Enum.all?(results, &match?({:ok, _}, &1)) ->
:ok
Enum.any?(results, &transient?/1) ->
{:error, {:transient, :partial}}
true ->
# Every send failed permanently (bot blocked/kicked everywhere, bad
# token, …). We still report :ok so one dead subscriber can't disable a
# working broadcast — but a wholly-failed send must not look like a
# success in the logs, or a mis-set token is invisible forever.
Logger.warning(
"[Notifications.Telegram] broadcast to #{length(targets)} chat(s) failed permanently: " <>
inspect(Enum.reject(results, &match?({:ok, _}, &1)))
)
:ok
end
end
defp send(uuid, chat_id, envelope) do
Client.send_message(uuid, chat_id, body(envelope), owner: {:user, envelope.recipient_uuid})
end
defp transient?({:error, reason}),
do:
match?({:error, {:transient, _}}, classify(reason)) or
match?({:error, {:transient, _}, _}, classify(reason))
defp transient?(_), do: false
# Plain text (no parse_mode) so notification content can't break Telegram
# markup. The deep-link goes on its own line.
defp body(%{text: text, url: url}) when is_binary(url) and url != "", do: "#{text}\n\n#{url}"
defp body(%{text: text}), do: text
# Map the send client's error into the permanent/transient taxonomy.
defp classify(:unreachable), do: {:error, {:transient, :unreachable}}
defp classify(reason) when reason in [:not_configured, :deleted],
do: {:error, {:permanent, reason}}
defp classify(desc) when is_binary(desc) do
down = String.downcase(desc)
cond do
String.contains?(down, "too many requests") ->
{:error, {:transient, desc}, retry_after_ms(desc)}
String.contains?(down, [
"blocked",
"deactivated",
"chat not found",
"bot can't initiate",
"bot can’t initiate",
"kicked",
"forbidden",
"have no rights",
"not enough rights"
]) ->
{:error, {:permanent, desc}}
String.contains?(down, [
"internal server error",
"bad gateway",
"gateway",
# any 5xx (the client formats a bodyless server error as "Telegram error 5xx")
"telegram error 5",
"502",
"503",
"504"
]) ->
{:error, {:transient, desc}}
true ->
{:error, {:permanent, desc}}
end
end
defp classify(other), do: {:error, {:permanent, other}}
defp retry_after_ms(desc) do
case Regex.run(~r/retry after (\d+)/i, desc) do
[_, seconds] -> String.to_integer(seconds) * 1000
_ -> 1000
end
end
end