Skip to main content

lib/gelotv_bot.ex

defmodule GelotvBot do
  @moduledoc """
  Multi-platform livestream chat bot dispatch library.

  `GelotvBot` coordinates supervised bot instances that can send the same
  command to multiple livestream chats on platforms such as Twitch, YouTube,
  and Kick. Platform-specific code is isolated behind `GelotvBot.Adapter` so
  applications can use official APIs, credentials, and policy-compliant send
  behavior for each service.
  """

  alias GelotvBot.{Bot, Command, Dispatcher, Message, Target}

  @type send_result :: Dispatcher.send_result()
  @type message_input :: Message.t() | Command.t() | String.t()
  @type message_input_or_many :: message_input() | [message_input()]
  @type target_input_or_many :: Target.t() | [Target.t()]

  @doc """
  Starts a supervised bot instance.

      {:ok, _pid} =
        GelotvBot.start_bot(:alerts,
          targets: [
            %GelotvBot.Target{
              platform: :twitch,
              channel: "gelotv",
              adapter: MyApp.TwitchAdapter
            }
          ]
        )
  """
  @spec start_bot(term(), keyword()) :: DynamicSupervisor.on_start_child()
  def start_bot(name, opts \\ []) do
    child = {Bot, Keyword.put(opts, :name, name)}
    DynamicSupervisor.start_child(GelotvBot.BotSupervisor, child)
  end

  @doc """
  Stops a supervised bot instance by name.
  """
  @spec stop_bot(term()) :: :ok | {:error, :not_found}
  def stop_bot(name) do
    case Registry.lookup(GelotvBot.Registry, name) do
      [{pid, _value}] -> DynamicSupervisor.terminate_child(GelotvBot.BotSupervisor, pid)
      [] -> {:error, :not_found}
    end
  end

  @doc """
  Lists currently running supervised bot instance names.
  """
  @spec list_bots() :: [term()]
  def list_bots do
    Registry.select(GelotvBot.Registry, [{{:"$1", :_, :_}, [], [:"$1"]}])
  end

  @doc """
  Broadcasts a message through a named supervised bot instance.
  """
  @spec send(term(), Message.t() | Command.t() | String.t(), keyword()) :: [send_result()]
  def send(bot_name, message, opts \\ []) do
    Bot.send_all(Bot.via(bot_name), message, opts)
  end

  @doc """
  Broadcasts multiple messages/commands through a named supervised bot instance.
  """
  @spec send_many(term(), [Message.t() | Command.t() | String.t()], keyword()) :: [send_result()]
  def send_many(bot_name, messages, opts \\ []) do
    Bot.send_many(Bot.via(bot_name), messages, opts)
  end

  @doc """
  Replaces the target list for a named supervised bot instance.
  """
  @spec put_targets(term(), [Target.t()]) :: :ok | {:error, term()}
  def put_targets(bot_name, targets) do
    Bot.put_targets(Bot.via(bot_name), targets)
  end

  @doc """
  Returns the target list for a named supervised bot instance.
  """
  @spec targets(term()) :: [Target.t()]
  def targets(bot_name) do
    Bot.targets(Bot.via(bot_name))
  end

  @doc """
  Sends a message directly to a list of targets without starting a named bot.
  """
  @spec broadcast([Target.t()], Message.t() | Command.t() | String.t(), keyword()) :: [
          send_result()
        ]
  def broadcast(targets, message, opts \\ [])

  def broadcast(targets, %Message{} = message, opts) do
    Dispatcher.send_all(targets, message, opts)
  end

  def broadcast(targets, %Command{} = command, opts) do
    Dispatcher.send_all(targets, Command.to_message(command), opts)
  end

  def broadcast(targets, body, opts) when is_binary(body) do
    Dispatcher.send_all(targets, Message.new(body), opts)
  end

  @doc """
  Sends multiple messages/commands directly to all targets.
  """
  @spec broadcast_many([Target.t()], [Message.t() | Command.t() | String.t()], keyword()) :: [
          send_result()
        ]
  def broadcast_many(targets, messages, opts \\ []) do
    Dispatcher.send_many(targets, messages, opts)
  end

  @doc """
  Dispatches one or many messages to one or many targets with one direct call.

  This is the most compact path for applications that do not want to manage a
  named bot process. A single target/message and a list of targets/messages use
  the same function.
  """
  @spec dispatch(target_input_or_many(), message_input_or_many(), keyword()) :: [send_result()]
  def dispatch(target_or_targets, message_or_messages, opts \\ []) do
    targets = List.wrap(target_or_targets)
    messages = List.wrap(message_or_messages)

    Dispatcher.send_many(targets, messages, opts)
  end

  @doc """
  Discovers active live targets for one or more platform specs.
  """
  @spec discover_live_targets([GelotvBot.LiveDiscovery.spec()], keyword()) ::
          {:ok, [Target.t()]} | {:error, term()}
  def discover_live_targets(specs, opts \\ []) do
    GelotvBot.LiveDiscovery.discover(specs, opts)
  end

  @doc """
  Discovers active live targets and sends multiple messages to all of them.
  """
  @spec broadcast_live(
          [GelotvBot.LiveDiscovery.spec()],
          message_input_or_many(),
          keyword()
        ) ::
          {:ok, [send_result()]} | {:error, term()}
  def broadcast_live(specs, messages, opts \\ []) do
    with {:ok, targets} <- discover_live_targets(specs, opts) do
      {:ok, dispatch(targets, messages, opts)}
    end
  end

  @doc """
  Alias for `broadcast_live/3` for callers that prefer send-oriented naming.
  """
  @spec send_live([GelotvBot.LiveDiscovery.spec()], message_input_or_many(), keyword()) ::
          {:ok, [send_result()]} | {:error, term()}
  def send_live(specs, messages, opts \\ []) do
    broadcast_live(specs, messages, opts)
  end

  @doc """
  Sends a raw request to a platform API using one unified function.

      GelotvBot.api_request(:twitch, :get, "/streams", credentials,
        params: [user_login: "gelotv"]
      )

  Use this when you need an endpoint that does not have a typed helper yet.
  """
  @spec api_request(:twitch | :youtube | :kick, atom(), String.t(), map(), keyword()) ::
          GelotvBot.HTTPClient.response()
  def api_request(platform, method, path, credentials, opts \\ [])

  def api_request(:twitch, method, path, credentials, opts) do
    GelotvBot.APIs.Twitch.request(method, path, credentials, opts)
  end

  def api_request(:youtube, method, path, credentials, opts) do
    GelotvBot.APIs.YouTube.request(method, path, credentials, opts)
  end

  def api_request(:kick, method, path, credentials, opts) do
    GelotvBot.APIs.Kick.request(method, path, credentials, opts)
  end

  @doc """
  Sends a platform API request and returns the decoded JSON body.

  The response keeps raw `:status`, `:headers`, and `:body` fields and adds
  `:decoded_body` for successful JSON responses.
  """
  @spec api_request_decoded(:twitch | :youtube | :kick, atom(), String.t(), map(), keyword()) ::
          {:ok, map()} | {:error, term()}
  def api_request_decoded(platform, method, path, credentials, opts \\ [])

  def api_request_decoded(:twitch, method, path, credentials, opts) do
    GelotvBot.APIs.Twitch.request_decoded(method, path, credentials, opts)
  end

  def api_request_decoded(:youtube, method, path, credentials, opts) do
    GelotvBot.APIs.YouTube.request_decoded(method, path, credentials, opts)
  end

  def api_request_decoded(:kick, method, path, credentials, opts) do
    GelotvBot.APIs.Kick.request_decoded(method, path, credentials, opts)
  end

  @doc """
  Collects paginated API responses using one function for all platforms.

  Twitch and YouTube use built-in cursor/page-token extractors. Kick and custom
  endpoints can pass `next: fn decoded_body -> next_params_or_nil end`.
  """
  @spec api_paginate(:twitch | :youtube | :kick, String.t(), map(), keyword()) ::
          {:ok, [map()]} | {:error, term()}
  def api_paginate(platform, path, credentials, opts \\ [])

  def api_paginate(:twitch, path, credentials, opts) do
    GelotvBot.APIs.Twitch.paginate(path, credentials, opts)
  end

  def api_paginate(:youtube, path, credentials, opts) do
    GelotvBot.APIs.YouTube.paginate(path, credentials, opts)
  end

  def api_paginate(:kick, path, credentials, opts) do
    GelotvBot.APIs.Kick.paginate(path, credentials, opts)
  end

  @doc """
  Requests an OAuth token using one function for supported platform helpers.
  """
  @spec token(:twitch | :youtube | :kick, :client_credentials | :refresh, map(), keyword()) ::
          {:ok, map()} | {:error, term()}
  def token(platform, grant, credentials, opts \\ [])

  def token(:twitch, :client_credentials, credentials, opts) do
    GelotvBot.APIs.Twitch.client_credentials_token(credentials, opts)
  end

  def token(:twitch, :refresh, credentials, opts) do
    GelotvBot.APIs.Twitch.refresh_token(credentials, opts)
  end

  def token(:youtube, :refresh, credentials, opts) do
    GelotvBot.APIs.YouTube.refresh_token(credentials, opts)
  end

  def token(:kick, :client_credentials, credentials, opts) do
    GelotvBot.APIs.Kick.client_credentials_token(credentials, opts)
  end

  def token(:kick, :refresh, credentials, opts) do
    GelotvBot.APIs.Kick.refresh_token(credentials, opts)
  end
end