defmodule Jido.Chat.Telegram.Extensions do
@moduledoc """
Telegram-specific extension API for features outside core `Jido.Chat.Adapter`.
This module is intentionally platform-specific and keeps Telegram-only semantics
out of `jido_chat` core abstractions.
"""
alias Jido.Chat.Telegram.{
CallbackAnswerOptions,
CallbackAnswerResult,
CallbackQuery,
DocumentOptions,
MediaMessage,
PhotoOptions,
UpdateEnvelope
}
alias Jido.Chat.Telegram.Transport.ExGramClient
@type extension_status :: :native | :fallback | :unsupported
@type extension_capabilities :: %{optional(atom()) => extension_status()}
@doc """
Returns Telegram extension capability statuses.
"""
@spec capabilities() :: extension_capabilities()
def capabilities do
%{
send_photo: :native,
send_document: :native,
answer_callback_query: :native,
parse_update_envelope: :native,
inline_keyboard: :native,
send_media_group: :unsupported,
forum_topics: :unsupported
}
end
@doc """
Parses a Telegram update into a typed extension envelope.
"""
@spec parse_update(map()) :: {:ok, UpdateEnvelope.t()} | {:error, term()}
def parse_update(update) when is_map(update) do
update_id = map_get(update, [:update_id, "update_id"])
cond do
is_map(map_get(update, [:callback_query, "callback_query"])) ->
callback_query = map_get(update, [:callback_query, "callback_query"])
{:ok,
UpdateEnvelope.new(%{
update_id: update_id,
update_type: :callback_query,
payload: normalize_callback_query(callback_query),
raw: update,
metadata: %{}
})}
is_map(map_get(update, [:message_reaction, "message_reaction"])) ->
{:ok,
UpdateEnvelope.new(%{
update_id: update_id,
update_type: :message_reaction,
payload: map_get(update, [:message_reaction, "message_reaction"]),
raw: update,
metadata: %{}
})}
is_map(map_get(update, [:edited_channel_post, "edited_channel_post"])) ->
{:ok,
UpdateEnvelope.new(%{
update_id: update_id,
update_type: :edited_channel_post,
payload: map_get(update, [:edited_channel_post, "edited_channel_post"]),
raw: update,
metadata: %{}
})}
is_map(map_get(update, [:channel_post, "channel_post"])) ->
{:ok,
UpdateEnvelope.new(%{
update_id: update_id,
update_type: :channel_post,
payload: map_get(update, [:channel_post, "channel_post"]),
raw: update,
metadata: %{}
})}
is_map(map_get(update, [:edited_message, "edited_message"])) ->
{:ok,
UpdateEnvelope.new(%{
update_id: update_id,
update_type: :edited_message,
payload: map_get(update, [:edited_message, "edited_message"]),
raw: update,
metadata: %{}
})}
is_map(map_get(update, [:message, "message"])) ->
{:ok,
UpdateEnvelope.new(%{
update_id: update_id,
update_type: :message,
payload: map_get(update, [:message, "message"]),
raw: update,
metadata: %{}
})}
true ->
{:ok,
UpdateEnvelope.new(%{
update_id: update_id,
update_type: :noop,
payload: nil,
raw: update,
metadata: %{reason: :unsupported_update_type}
})}
end
end
@doc """
Sends a Telegram photo message and returns a typed media result.
"""
@spec send_photo(
String.t() | integer(),
String.t() | {:file, String.t()} | {:file_content, binary(), String.t()},
keyword() | map() | PhotoOptions.t()
) ::
{:ok, MediaMessage.t()} | {:error, term()}
def send_photo(chat_id, photo, opts \\ []) do
opts = PhotoOptions.new(opts)
token = fetch_token(opts.token)
payload =
PhotoOptions.payload_opts(opts)
|> Map.merge(%{"chat_id" => chat_id, "photo" => photo})
with {:ok, result} <-
transport(opts).call(token, "sendPhoto", payload, PhotoOptions.transport_opts(opts)) do
{:ok, normalize_media_result(:photo, result, chat_id)}
end
end
@doc """
Sends a Telegram document message and returns a typed media result.
"""
@spec send_document(
String.t() | integer(),
String.t() | {:file, String.t()} | {:file_content, binary(), String.t()},
keyword() | map() | DocumentOptions.t()
) ::
{:ok, MediaMessage.t()} | {:error, term()}
def send_document(chat_id, document, opts \\ []) do
opts = DocumentOptions.new(opts)
token = fetch_token(opts.token)
payload =
DocumentOptions.payload_opts(opts)
|> Map.merge(%{"chat_id" => chat_id, "document" => document})
with {:ok, result} <-
transport(opts).call(
token,
"sendDocument",
payload,
DocumentOptions.transport_opts(opts)
) do
{:ok, normalize_media_result(:document, result, chat_id)}
end
end
@doc """
Answers a Telegram callback query.
"""
@spec answer_callback_query(
String.t(),
keyword() | map() | CallbackAnswerOptions.t()
) ::
{:ok, CallbackAnswerResult.t()} | {:error, term()}
def answer_callback_query(callback_query_id, opts \\ []) when is_binary(callback_query_id) do
opts = CallbackAnswerOptions.new(opts)
token = fetch_token(opts.token)
payload =
CallbackAnswerOptions.payload_opts(opts)
|> Map.put("callback_query_id", callback_query_id)
with {:ok, result} <-
transport(opts).call(
token,
"answerCallbackQuery",
payload,
CallbackAnswerOptions.transport_opts(opts)
) do
{:ok,
CallbackAnswerResult.new(%{
callback_query_id: callback_query_id,
answered: true,
raw: result
})}
end
end
defp normalize_callback_query(callback_query) when is_map(callback_query) do
message = map_get(callback_query, [:message, "message"]) || %{}
chat = map_get(message, [:chat, "chat"]) || %{}
CallbackQuery.new(%{
id: to_string(map_get(callback_query, [:id, "id"]) || Jido.Chat.ID.generate!()),
data: map_get(callback_query, [:data, "data"]),
from: map_get(callback_query, [:from, "from"]),
chat_id: map_get(chat, [:id, "id"]),
message_id: map_get(message, [:message_id, "message_id"]),
inline_message_id: map_get(callback_query, [:inline_message_id, "inline_message_id"]),
raw: callback_query
})
end
defp normalize_media_result(kind, result, chat_id) when is_map(result) do
file_id =
case kind do
:photo ->
result
|> map_get([:photo, "photo"])
|> pick_photo_file_id()
:document ->
result
|> map_get([:document, "document"])
|> map_get([:file_id, "file_id"])
end
MediaMessage.new(%{
kind: kind,
message_id: to_string(map_get(result, [:message_id, "message_id"]) || Jido.Chat.ID.generate!()),
chat_id: map_get(result, [:chat, "chat"]) |> map_get([:id, "id"]) || chat_id,
date: map_get(result, [:date, "date"]),
caption: map_get(result, [:caption, "caption"]),
file_id: stringify(file_id),
raw: result
})
end
defp normalize_media_result(kind, result, chat_id) do
MediaMessage.new(%{
kind: kind,
chat_id: chat_id,
raw: result,
metadata: %{coerced: true}
})
end
defp pick_photo_file_id(list) when is_list(list) do
list
|> List.last()
|> map_get([:file_id, "file_id"])
end
defp pick_photo_file_id(_), do: nil
defp transport(%{transport: transport}) when not is_nil(transport), do: transport
defp transport(_opts), do: ExGramClient
defp fetch_token(token) do
token || Application.get_env(:jido_chat_telegram, :telegram_bot_token) ||
raise ArgumentError,
"missing Telegram bot token; pass :token option or configure :jido_chat_telegram, :telegram_bot_token"
end
defp map_get(nil, _keys), do: nil
defp map_get(map, keys) when is_map(map) and is_list(keys) do
Enum.find_value(keys, fn key -> Map.get(map, key) end)
end
defp map_get(_other, _keys), do: nil
defp stringify(nil), do: nil
defp stringify(value) when is_binary(value), do: value
defp stringify(value), do: to_string(value)
end