lib/openagents_web/controllers/voice_recording_controller.ex

58e6347eeb72 · 5 KB

defmodule OpenAgentsWeb.VoiceRecordingController do
  @moduledoc """
  Accepts the browser's slices of call audio.

  Deliberately shaped like `OpenAgentsWeb.VoiceTelemetryController`: the session is
  resolved from the encrypted session cookie and the caller's own conversation,
  never from a client-supplied identifier, and a refusal is a bounded code rather
  than provider or database prose.

  A failure here must never take a call down. The browser treats every non-2xx
  as "stop recording, keep talking", which is why the responses are terse and why
  a disabled recorder answers with the same shape as a full one.
  """

  use OpenAgentsWeb, :controller

  import Plug.Conn

  alias OpenAgents.Conversations
  alias OpenAgents.Voice
  alias OpenAgents.Voice.Config
  alias OpenAgents.Voice.Recordings

  def create(conn, _params) do
    settings = Recordings.config()

    with %Config{enabled?: true} <- Config.current!(),
         :ok <- require_recording_enabled(settings),
         {:ok, generation} <- header_integer(conn, "x-voice-generation"),
         {:ok, sequence} <- header_integer(conn, "x-voice-recording-sequence"),
         {:ok, content_type} <- media_type(conn),
         {:ok, session} <- active_session(conn),
         {:ok, chunk, conn} <- read_chunk(conn, settings.maximum_chunk_bytes),
         {:ok, _recording} <-
           Recordings.append_chunk(session, generation, sequence, chunk, content_type) do
      send_resp(conn, :no_content, "")
    else
      %Config{enabled?: false} -> recording_error(conn, :service_unavailable, "voice_unavailable")
      {:error, reason} -> recording_error(conn, status_for(reason), code_for(reason))
      _unavailable -> recording_error(conn, :service_unavailable, "voice_recording_unavailable")
    end
  end

  def complete(conn, params) do
    with %Config{enabled?: true} <- Config.current!(),
         {:ok, generation} <- header_integer(conn, "x-voice-generation"),
         {:ok, status} <- recording_status(params),
         {:ok, session} <- active_session(conn),
         {:ok, _recording} <-
           Recordings.finalize(session, generation, status, duration_ms(params)) do
      send_resp(conn, :no_content, "")
    else
      # Nothing to close is not a client error: the browser finalizes on every
      # exit path, including ones where no audio was ever uploaded.
      {:error, :voice_recording_not_found} -> send_resp(conn, :no_content, "")
      %Config{enabled?: false} -> recording_error(conn, :service_unavailable, "voice_unavailable")
      {:error, reason} -> recording_error(conn, status_for(reason), code_for(reason))
      _unavailable -> recording_error(conn, :service_unavailable, "voice_recording_unavailable")
    end
  end

  defp require_recording_enabled(%{enabled?: true}), do: :ok
  defp require_recording_enabled(%{enabled?: false}), do: {:error, :voice_recording_disabled}

  # The session is the caller's own most recent one rather than the active one:
  # the last slice and the finalize both arrive after the call has ended.
  defp active_session(conn) do
    with %{status: "active"} = user <- conn.assigns.current_user,
         conversation when not is_nil(conversation) <-
           Conversations.get_conversation_for_user(user),
         session when not is_nil(session) <- Voice.latest_session(conversation) do
      {:ok, session}
    else
      _missing -> {:error, :voice_session_not_found}
    end
  end

  defp read_chunk(conn, maximum_bytes) do
    case read_body(conn, length: maximum_bytes, read_length: 65_536) do
      {:ok, body, next_conn} when byte_size(body) > 0 -> {:ok, body, next_conn}
      {:ok, _empty, _next_conn} -> {:error, :invalid_recording_chunk}
      {:more, _partial, _next_conn} -> {:error, :voice_recording_chunk_too_large}
      {:error, _reason} -> {:error, :invalid_recording_chunk}
    end
  end

  defp media_type(conn) do
    case get_req_header(conn, "content-type") do
      [content_type] -> {:ok, content_type}
      _missing_or_repeated -> {:error, :unsupported_recording_media_type}
    end
  end

  defp header_integer(conn, name) do
    with [value] <- get_req_header(conn, name),
         true <- Regex.match?(~r/\A[1-9][0-9]{0,8}\z/, value) do
      {:ok, String.to_integer(value)}
    else
      _invalid -> {:error, :invalid_recording_chunk}
    end
  end

  defp recording_status(%{"status" => status}) when status in ~w(complete failed),
    do: {:ok, status}

  defp recording_status(_params), do: {:ok, "complete"}

  defp duration_ms(%{"duration_ms" => duration}) when is_integer(duration) and duration >= 0,
    do: min(duration, 24 * 60 * 60 * 1_000)

  defp duration_ms(_params), do: nil

  defp status_for(:voice_recording_chunk_too_large), do: :request_entity_too_large
  defp status_for(:voice_recording_limit_reached), do: :request_entity_too_large
  defp status_for(:unsupported_recording_media_type), do: :unsupported_media_type
  defp status_for(:voice_recording_disabled), do: :service_unavailable
  defp status_for(:voice_recording_unavailable), do: :service_unavailable
  defp status_for(:voice_session_not_found), do: :conflict
  defp status_for(:stale_voice_generation), do: :conflict
  defp status_for(:voice_recording_closed), do: :conflict
  defp status_for(:voice_recording_window_closed), do: :conflict
  defp status_for(:voice_recording_sequence_gap), do: :conflict
  defp status_for(_reason), do: :bad_request

  defp code_for(reason) when is_atom(reason), do: Atom.to_string(reason)
  defp code_for(%Ecto.Changeset{}), do: "invalid_recording_chunk"
  defp code_for(_reason), do: "voice_recording_unavailable"

  defp recording_error(conn, status, code) do
    conn
    |> put_status(status)
    |> put_resp_content_type("application/json")
    |> json(%{error: code})
  end
end