18.0 KiB · text History 4b6fe3b
defmodule OhMyExOhIrcd.IRC.ScrobbleServ do
  @moduledoc """
  Pseudo-service that announces "now playing" scrobbles into channels.

  Like `ChanServ`, it lives as a Horde-supervised cluster-wide singleton
  registered under `{:user, downcased_nick}`, so PRIVMSGs and INVITEs to
  it from anywhere in the cluster route here.

  ScrobbleServ is *invited* into a registered channel (`/invite
  ScrobbleServ #chan` by the founder or a channel op), where it joins as a
  voiced member — voice is required so a `+m` (moderated) channel doesn't
  silently drop its announcements. It is configured per-channel via direct
  messages (`/msg ScrobbleServ HELP`) and via the web UI.

  Scrobbles arrive out-of-band over an authenticated HTTP endpoint (see
  `OhMyExOhIrcdWeb.ScrobbleController`), which calls `announce/5`. The
  service checks the channel config (enabled / allowlist / rate limit),
  posts the rendered line to the channel, and records it for the web UI.

  Config and history live in `OhMyExOhIrcd.Scrobbles`. Rate-limit state is
  in-memory (per `{channel, who}`), so it resets if the singleton restarts
  — acceptable for a "now playing" bot.
  """

  use GenServer, restart: :transient
  require Logger

  alias OhMyExOhIrcd.Cluster.{HordeRegistry, HordeSupervisor}
  alias OhMyExOhIrcd.{Accounts, Channels, Scrobbles}
  alias OhMyExOhIrcd.IRC.Channel

  # Voice rank keeps announcements flowing through `+m`.
  @join_access :voice
  @announce_timeout 5_000
  @max_line 400

  ## Public API

  @doc "Configured ScrobbleServ nick (default `ScrobbleServ`)."
  def nick do
    Application.get_env(:oh_my_ex_oh_ircd, :services, [])
    |> Keyword.get(:scrobbleserv_nick, "ScrobbleServ")
  end

  def host do
    Application.get_env(:oh_my_ex_oh_ircd, :services, [])
    |> Keyword.get(:service_host, "services.")
  end

  @doc "Ensure ScrobbleServ is running somewhere in the cluster."
  def ensure_started do
    key = {:user, String.downcase(nick())}

    case HordeRegistry.whereis(key) do
      pid when is_pid(pid) ->
        {:ok, pid}

      nil ->
        spec = %{
          id: __MODULE__,
          start: {__MODULE__, :start_link, []},
          restart: :transient
        }

        case Horde.DynamicSupervisor.start_child(HordeSupervisor, spec) do
          {:ok, pid} -> {:ok, pid}
          {:error, {:already_started, pid}} -> {:ok, pid}
          other -> other
        end
    end
  end

  def start_link(_opts \\ []) do
    GenServer.start_link(__MODULE__, [],
      name: HordeRegistry.via({:user, String.downcase(nick())})
    )
  end

  @doc """
  Announce a scrobble into `channel`. Returns `:ok` when posted,
  `{:ignored, reason}` when suppressed by config (`:disabled`,
  `:not_allowed`, `:rate_limited`), `{:error, :not_joined}` when the
  service isn't set up in that channel, or `{:error, :unavailable}` if the
  singleton can't be reached / times out.
  """
  def announce(channel, who, artist, track, album \\ nil) do
    case HordeRegistry.whereis({:user, String.downcase(nick())}) do
      pid when is_pid(pid) ->
        try do
          GenServer.call(pid, {:announce, channel, who, artist, track, album}, @announce_timeout)
        catch
          _, _ -> {:error, :unavailable}
        end

      nil ->
        {:error, :unavailable}
    end
  end

  ## Server

  @impl true
  def init(_) do
    Logger.info("ScrobbleServ online as #{nick()}")
    {:ok, %{rate: %{}}, {:continue, :rejoin}}
  end

  @impl true
  def handle_continue(:rejoin, state) do
    for channel <- safe_joined_channels() do
      join_channel(channel)
    end

    {:noreply, state}
  end

  # WHOIS support.
  @impl true
  def handle_call(:whois_info, _from, state) do
    info = %{
      nick: nick(),
      user: "service",
      host: host(),
      realname: "Scrobble Services",
      channels: [],
      idle: 0,
      signon: 0,
      away: nil,
      account: nil
    }

    {:reply, info, state}
  end

  def handle_call({:announce, channel, who, artist, track, album}, _from, state) do
    case Scrobbles.get_config(channel) do
      nil ->
        {:reply, {:error, :not_joined}, state}

      %{joined: false} ->
        {:reply, {:error, :not_joined}, state}

      %{enabled: false} ->
        {:reply, {:ignored, :disabled}, state}

      cfg ->
        do_announce(state, channel, cfg, who, artist, track, album)
    end
  end

  ## Invites / kicks

  @impl true
  def handle_info({:irc_invite, inviter, channel}, state) do
    case require_op(channel, inviter) do
      {:ok, _user} ->
        case Scrobbles.ensure_config(channel) do
          {:ok, _cfg} ->
            Scrobbles.set_joined(channel, true)
            join_channel(channel)

            reply(inviter, [
              "Joined #{channel}. Announcements are ON.",
              "Mint an API token with: TOKEN NEW #{channel}",
              "See all commands with: HELP"
            ])

          {:error, :not_registered} ->
            reply(inviter, "#{channel} must be registered with ChanServ before I can join.")

          {:error, _cs} ->
            reply(inviter, "Could not set up scrobbling on #{channel}.")
        end

      :stop ->
        :ok
    end

    {:noreply, state}
  end

  def handle_info({:irc_kicked, channel}, state) do
    Scrobbles.set_joined(channel, false)
    {:noreply, %{state | rate: drop_channel_rate(state.rate, channel)}}
  end

  def handle_info({:irc_privmsg, %{sender: sender, text: text}}, state) do
    sender_nick = extract_nick(sender)
    Logger.debug("ScrobbleServ <- #{sender_nick}: #{text}")
    state = handle_command(text, sender_nick, state)
    {:noreply, state}
  end

  def handle_info(_, state), do: {:noreply, state}

  ## Announce internals

  defp do_announce(state, channel, cfg, who, artist, track, album) do
    who_norm = normalize_who(who)

    cond do
      cfg.restrict_to_allowlist and who_norm not in cfg.allowlist ->
        {:reply, {:ignored, :not_allowed}, state}

      rate_limited?(state.rate, channel, who_norm, cfg.rate_limit_seconds) ->
        {:reply, {:ignored, :rate_limited}, state}

      true ->
        ensure_member(channel)
        text = render(cfg.format, who, artist, track, album)
        Channel.privmsg(channel, service_prefix(), text)

        {:reply, :ok, %{state | rate: mark_rate(state.rate, channel, who_norm)}}
    end
  end

  defp render(format, who, artist, track, album) do
    format
    |> String.replace("%who%", strip(who))
    |> String.replace("%artist%", strip(artist))
    |> String.replace("%track%", strip(track))
    |> String.replace("%album%", strip(album || ""))
    |> clip(@max_line)
  end

  # Re-join if the Channel singleton restarted out from under us.
  defp ensure_member(channel) do
    case Channel.member_info(channel, nick()) do
      %{access: _} -> :ok
      _ -> join_channel(channel)
    end
  catch
    _, _ -> :ok
  end

  defp join_channel(channel) do
    Channel.join(channel, nick(), self(), nil, service_prefix())
    Channel.service_set_access(channel, nick(), @join_access, nick())
    :ok
  catch
    _, _ -> :ok
  end

  ## Rate limiting (in-memory, per {downcased channel, downcased who})

  defp rate_limited?(_rate, _channel, _who, seconds) when seconds <= 0, do: false

  defp rate_limited?(rate, channel, who, seconds) do
    case Map.get(rate, {downcase(channel), who}) do
      nil -> false
      last -> now() - last < seconds
    end
  end

  defp mark_rate(rate, channel, who), do: Map.put(rate, {downcase(channel), who}, now())

  defp drop_channel_rate(rate, channel) do
    down = downcase(channel)
    rate |> Enum.reject(fn {{c, _w}, _} -> c == down end) |> Map.new()
  end

  ## Command dispatch

  defp handle_command(text, who, state) do
    case String.split(text, ~r/\s+/, trim: true) do
      [] -> state
      [cmd | args] -> do_command(String.upcase(cmd), args, who, state)
    end
  end

  defp do_command("HELP", _args, who, state) do
    reply(who, [
      "ScrobbleServ announces \"now playing\" scrobbles into channels.",
      "Invite me with /invite ScrobbleServ #channel (founder/op of a registered channel).",
      "Commands (all but HELP require you to be founder/op of the channel):",
      "  INFO #chan                 Show current configuration.",
      "  ENABLE / DISABLE #chan     Turn announcements on/off.",
      "  FORMAT #chan <template>    Set the line. Tokens: %who% %artist% %track% %album%.",
      "  RATELIMIT #chan <seconds>  Min seconds between a who's announcements (0 = off).",
      "  RESTRICT #chan ON|OFF      Only announce allowlisted who-names.",
      "  ALLOW / DENY #chan <who>   Add/remove a who-name from the allowlist.",
      "  ALLOWLIST #chan            Show the allowlist.",
      "  TOKEN NEW #chan <who> [label]  Mint an API token bound to you (shown once).",
      "  TOKEN LIST #chan           List tokens (who + label).",
      "  TOKEN REVOKE #chan <id>    Revoke a token by id.",
      "  PART #chan                 Leave the channel and stop announcing."
    ])

    state
  end

  defp do_command("INFO", [channel], who, state) do
    with_op(channel, who, state, fn ->
      case Scrobbles.get_config(channel) do
        nil ->
          reply(who, "#{channel} has no scrobble config. Invite me first.")

        cfg ->
          reply(who, [
            "#{channel} scrobble config:",
            "  Joined:    #{cfg.joined}",
            "  Enabled:   #{cfg.enabled}",
            "  Format:    #{cfg.format}",
            "  Ratelimit: #{cfg.rate_limit_seconds}s",
            "  Restrict:  #{cfg.restrict_to_allowlist} (#{length(cfg.allowlist)} allowed)"
          ])
      end
    end)
  end

  defp do_command("ENABLE", [channel], who, state) do
    with_op(channel, who, state, fn ->
      Scrobbles.set_enabled(channel, true)
      reply(who, "Announcements ENABLED on #{channel}.")
    end)
  end

  defp do_command("DISABLE", [channel], who, state) do
    with_op(channel, who, state, fn ->
      Scrobbles.set_enabled(channel, false)
      reply(who, "Announcements DISABLED on #{channel}.")
    end)
  end

  defp do_command("FORMAT", [channel | rest], who, state) when rest != [] do
    with_op(channel, who, state, fn ->
      template = Enum.join(rest, " ")

      case Scrobbles.set_format(channel, template) do
        {:ok, cfg} -> reply(who, "Format for #{channel} set to: #{cfg.format}")
        _ -> reply(who, "Could not set format.")
      end
    end)
  end

  defp do_command("RATELIMIT", [channel, seconds], who, state) do
    with_op(channel, who, state, fn ->
      case Integer.parse(seconds) do
        {n, ""} when n >= 0 ->
          case Scrobbles.set_rate_limit(channel, n) do
            {:ok, _} -> reply(who, "Rate limit for #{channel} set to #{n}s.")
            _ -> reply(who, "Could not set rate limit.")
          end

        _ ->
          reply(who, "Usage: RATELIMIT #{channel} <seconds>")
      end
    end)
  end

  defp do_command("RESTRICT", [channel, toggle], who, state) do
    with_op(channel, who, state, fn ->
      case parse_toggle(toggle) do
        {:ok, value} ->
          Scrobbles.set_restrict(channel, value)
          reply(who, "Allowlist enforcement on #{channel}: #{if value, do: "ON", else: "OFF"}.")

        :error ->
          reply(who, "Usage: RESTRICT #{channel} ON|OFF")
      end
    end)
  end

  defp do_command("ALLOW", [channel, target], who, state) do
    with_op(channel, who, state, fn ->
      Scrobbles.add_allow(channel, target)
      reply(who, "Added #{target} to the #{channel} allowlist.")
    end)
  end

  defp do_command("DENY", [channel, target], who, state) do
    with_op(channel, who, state, fn ->
      Scrobbles.remove_allow(channel, target)
      reply(who, "Removed #{target} from the #{channel} allowlist.")
    end)
  end

  defp do_command("ALLOWLIST", [channel], who, state) do
    with_op(channel, who, state, fn ->
      case Scrobbles.get_config(channel) do
        %{allowlist: [_ | _] = list} ->
          reply(who, "#{channel} allowlist: " <> Enum.join(list, ", "))

        _ ->
          reply(who, "#{channel} allowlist is empty.")
      end
    end)
  end

  defp do_command("TOKEN", ["NEW", channel, who_name | rest], who, state) do
    with_op(channel, who, state, fn ->
      label = if rest == [], do: nil, else: Enum.join(rest, " ")

      case op_user(channel, who) do
        nil ->
          reply(who, "Could not mint a token.")

        user ->
          case Scrobbles.mint_token(channel, user, who_name, label) do
            {:ok, display, _token} ->
              reply(who, [
                "New scrobble token for #{channel} as \"#{who_name}\" (shown once — store it now):",
                "  #{display}",
                "POST it as `Authorization: Bearer <token>` to /api/v1/scrobble."
              ])

            _ ->
              reply(who, "Could not mint a token.")
          end
      end
    end)
  end

  defp do_command("TOKEN", ["NEW" | _], who, state) do
    reply(who, "Usage: TOKEN NEW #channel <who> [label]")
    state
  end

  defp do_command("TOKEN", ["LIST", channel], who, state) do
    with_op(channel, who, state, fn ->
      case Scrobbles.list_tokens(channel) do
        [] ->
          reply(who, "#{channel} has no active tokens.")

        tokens ->
          reply(who, "Active tokens for #{channel}:")

          Enum.each(tokens, &reply_token_line(who, &1))
      end
    end)
  end

  defp do_command("TOKEN", ["REVOKE", channel, id], who, state) do
    with_op(channel, who, state, fn ->
      case Integer.parse(id) do
        {token_id, ""} ->
          Scrobbles.revoke_token(channel, token_id)
          reply(who, "Token ##{token_id} on #{channel} revoked (if it existed).")

        _ ->
          reply(who, "Usage: TOKEN REVOKE #{channel} <id>")
      end
    end)
  end

  defp do_command("TOKEN", _args, who, state) do
    reply(who, "Usage: TOKEN NEW|LIST|REVOKE #channel [args]")
    state
  end

  defp do_command("PART", [channel], who, state) do
    case require_op(channel, who) do
      {:ok, _user} ->
        Scrobbles.set_joined(channel, false)
        Channel.part(channel, nick(), "Scrobbling disabled")
        reply(who, "Left #{channel}. I'll stop announcing there.")
        %{state | rate: drop_channel_rate(state.rate, channel)}

      :stop ->
        state
    end
  end

  defp do_command(other, _args, who, state) do
    reply(who, "Unknown command: #{other}. Try HELP.")
    state
  end

  defp reply_token_line(who, t) do
    used = if t.last_used_at, do: DateTime.to_iso8601(t.last_used_at), else: "never"
    label = if t.label, do: " (#{t.label})", else: ""
    reply(who, "  ##{t.id} as #{t.who}#{label} — last used: #{used}")
  end

  ## Authorization

  # Run `fun` only if `who` is founder/op of the registered `channel`.
  # Always returns `state` (commands don't mutate it except PART/rate).
  defp with_op(channel, who, state, fun) do
    case require_op(channel, who) do
      {:ok, _user} -> fun.()
      :stop -> :ok
    end

    state
  end

  defp require_op(channel, who) do
    case Channels.get(channel) do
      nil ->
        reply(who, "#{channel} is not registered. Register it with ChanServ first.")
        :stop

      rec ->
        with {:ok, user} <- require_account(who) do
          if op?(user, who, channel, rec) do
            {:ok, user}
          else
            reply(who, "Only the channel founder or an op may do that.")
            :stop
          end
        end
    end
  end

  defp require_account(who) do
    case account_for(who) do
      {:ok, user} ->
        {:ok, user}

      :error ->
        reply(who, "You must be logged in. Connect with SASL or PASS using your account.")
        :stop
    end
  end

  defp op?(user, who, channel, rec) do
    cond do
      rec.founder_user_id == user.id -> true
      persisted_op?(user, channel) -> true
      live_op?(who, channel) -> true
      true -> false
    end
  end

  defp persisted_op?(user, channel) do
    down = downcase(channel)

    Channels.list_for_user(user)
    |> Enum.any?(fn %{record: r, role: role} ->
      r.name == down and role in [:founder, :admin, :op]
    end)
  end

  defp live_op?(who, channel) do
    case Channel.member_info(channel, who) do
      %{access: a} -> Channel.rank(a) >= Channel.rank(:op)
      _ -> false
    end
  catch
    _, _ -> false
  end

  defp op_user(channel, who) do
    case Channels.get(channel) do
      nil ->
        nil

      _ ->
        case account_for(who) do
          {:ok, user} -> user
          _ -> nil
        end
    end
  end

  defp account_for(who) do
    case HordeRegistry.whereis({:user, String.downcase(who)}) do
      pid when is_pid(pid) ->
        try do
          info = GenServer.call(pid, :whois_info, 2_000)

          case info[:account] && Accounts.get_user_by_email(info.account) do
            %_{} = user -> {:ok, user}
            _ -> :error
          end
        catch
          _, _ -> :error
        end

      nil ->
        :error
    end
  end

  ## Reply helpers

  defp reply(target_nick, lines) when is_list(lines),
    do: Enum.each(lines, &reply(target_nick, &1))

  defp reply(target_nick, line) when is_binary(line) do
    case HordeRegistry.whereis({:user, String.downcase(target_nick)}) do
      pid when is_pid(pid) ->
        send(
          pid,
          {:irc_privmsg,
           %{
             sender: nick(),
             target: target_nick,
             text: line,
             kind: :notice
           }}
        )

      nil ->
        :ok
    end
  end

  ## Misc

  defp safe_joined_channels do
    Scrobbles.list_joined_channels()
  rescue
    _ -> []
  end

  defp parse_toggle(t) do
    case String.upcase(t) do
      v when v in ["ON", "TRUE", "1", "YES"] -> {:ok, true}
      v when v in ["OFF", "FALSE", "0", "NO"] -> {:ok, false}
      _ -> :error
    end
  end

  defp extract_nick(prefix) when is_binary(prefix) do
    case String.split(prefix, "!", parts: 2) do
      [n | _] -> n
    end
  end

  defp normalize_who(who), do: who |> String.trim() |> String.downcase()

  defp strip(s) when is_binary(s), do: String.replace(s, ~r/[\x00-\x1f]/, "")
  defp strip(_), do: ""

  defp clip(nil, _), do: nil
  defp clip(s, max) when is_binary(s), do: String.slice(s, 0, max)

  defp downcase(s) when is_binary(s), do: String.downcase(s)

  defp now, do: System.system_time(:second)

  defp service_prefix, do: "#{nick()}!service@#{host()}"
end