defmodule Raxol.Telegram.Session do @moduledoc """ Per-chat Telegram session. Manages a Raxol Lifecycle instance for a single Telegram chat. Receives render callbacks via `io_writer` and sends/edits messages in the chat. Dispatches input events to the TEA update cycle. The session auto-stops after an idle timeout (default 10 minutes). """ use GenServer require Logger alias Raxol.Telegram.OutputAdapter @compile {:no_warn_undefined, [Raxol.Core.Runtime.Lifecycle]} @default_idle_timeout 10 * 60 * 1000 defstruct [ :app_module, :chat_id, :lifecycle_pid, :dispatcher_pid, :idle_timeout, :idle_timer, :last_message_id, :last_html ] def start_link(opts) do GenServer.start_link(__MODULE__, opts) end @doc """ Dispatches an event to this session's TEA lifecycle. """ @spec dispatch(pid(), Raxol.Core.Events.Event.t()) :: :ok def dispatch(pid, event) do GenServer.cast(pid, {:dispatch, event}) end @doc """ Called by the rendering backend to deliver a frame. The session edits the existing message or sends a new one. """ @spec render(pid(), String.t(), [[map()]]) :: :ok def render(pid, html, keyboard) do GenServer.cast(pid, {:render, html, keyboard}) end # -- GenServer Callbacks -- @impl true def init(opts) do app_module = Keyword.fetch!(opts, :app_module) chat_id = Keyword.fetch!(opts, :chat_id) idle_timeout = Keyword.get(opts, :idle_timeout, @default_idle_timeout) state = %__MODULE__{ app_module: app_module, chat_id: chat_id, idle_timeout: idle_timeout } # Start the TEA lifecycle in :telegram environment. # The io_writer callback is set to deliver frames back to this session. session_pid = self() {width, height} = OutputAdapter.default_size() io_writer = fn render_data -> send(session_pid, {:io_write, render_data}) end case start_lifecycle(app_module, width, height, io_writer) do {:ok, lifecycle_pid, dispatcher_pid} -> timer = schedule_idle_timeout(idle_timeout) {:ok, %{ state | lifecycle_pid: lifecycle_pid, dispatcher_pid: dispatcher_pid, idle_timer: timer }} {:error, reason} -> {:stop, reason} end end @impl true def handle_cast({:dispatch, event}, state) do if state.dispatcher_pid do GenServer.cast(state.dispatcher_pid, {:dispatch, event}) end {:noreply, reset_idle_timer(state)} end def handle_cast({:render, html, keyboard}, state) do new_state = do_send_or_edit(html, keyboard, state) {:noreply, new_state} end @impl true def handle_info({:io_write, render_data}, state) do {html, keyboard} = format_render_data(render_data) new_state = do_send_or_edit(html, keyboard, state) {:noreply, new_state} end def handle_info(:idle_timeout, state) do {:stop, :normal, state} end def handle_info(_, state), do: {:noreply, state} @impl true def terminate(_reason, state) do if state.lifecycle_pid && Process.alive?(state.lifecycle_pid) do try do GenServer.stop(state.lifecycle_pid, :normal, 1000) catch # Process may have already exited between alive? check and stop call :exit, {:noproc, _} -> :ok :exit, {:normal, _} -> :ok end end :ok end # -- Private -- # Lifecycle io_writer callback delivers render data in several shapes: # - %{buffer: buffer, view_tree: tree} -- full render with widget tree # - %{buffer: buffer} -- buffer-only render (no tree) # - binary string -- raw text output # - other -- fallback placeholder defp format_render_data(%{buffer: buffer, view_tree: view_tree}), do: OutputAdapter.format_message(buffer, view_tree) defp format_render_data(%{buffer: buffer}), do: OutputAdapter.format_message(buffer) defp format_render_data(text) when is_binary(text), do: {"
#{OutputAdapter.escape_html(text)}", OutputAdapter.default_keyboard()}
defp format_render_data(_),
do: {"...", OutputAdapter.default_keyboard()} defp start_lifecycle(app_module, width, height, io_writer) do if Code.ensure_loaded?(Raxol.Core.Runtime.Lifecycle) do case Raxol.Core.Runtime.Lifecycle.start_link(app_module, environment: :telegram, width: width, height: height, io_writer: io_writer ) do {:ok, lifecycle_pid} -> # Get dispatcher pid from lifecycle state dispatcher_pid = try do case GenServer.call(lifecycle_pid, :get_full_state, 5000) do %{dispatcher_pid: pid} -> pid _ -> nil end catch # Lifecycle may not respond if it's still initializing or crashed :exit, {:timeout, _} -> nil :exit, {:noproc, _} -> nil end if is_nil(dispatcher_pid) do Logger.warning( "Telegram session for #{inspect(app_module)}: dispatcher_pid is nil, events will be dropped" ) end {:ok, lifecycle_pid, dispatcher_pid} error -> error end else {:error, :lifecycle_not_available} end end defp do_send_or_edit(html, keyboard, state) do # Skip if content hasn't changed if html == state.last_html do state else message_id = send_or_edit_message(state.chat_id, html, keyboard, state.last_message_id) %{state | last_message_id: message_id, last_html: html} end end defp send_or_edit_message(chat_id, html, keyboard, last_message_id) do reply_markup = %{inline_keyboard: keyboard} if Code.ensure_loaded?(Telegex) do if last_message_id do # Edit existing message to avoid spam case Telegex.edit_message_text(html, chat_id: chat_id, message_id: last_message_id, parse_mode: "HTML", reply_markup: reply_markup ) do {:ok, _msg} -> last_message_id {:error, _} -> # Edit failed, send new message send_new_message(chat_id, html, reply_markup) end else send_new_message(chat_id, html, reply_markup) end else # Telegex not available, log and return nil last_message_id end end defp send_new_message(chat_id, html, reply_markup) do case Telegex.send_message(chat_id, html, parse_mode: "HTML", reply_markup: reply_markup ) do {:ok, msg} -> Map.get(msg, :message_id) {:error, _} -> nil end end defp schedule_idle_timeout(timeout) do Process.send_after(self(), :idle_timeout, timeout) end defp reset_idle_timer(state) do if state.idle_timer, do: Process.cancel_timer(state.idle_timer) %{state | idle_timer: schedule_idle_timeout(state.idle_timeout)} end end