neiam
/oh-my-ex-oh-ircd
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
scroobbler
6ccfeee · gmorell · 2026-06-11 16:05
Files changed
modified
config/config.exs
+1
−0
| 29 | 29 | # PRIVMSGs to them route to the corresponding service process. |
| 30 | 30 | config :oh_my_ex_oh_ircd, :services, |
| 31 | 31 | chanserv_nick: "ChanServ", |
| 32 | + scrobbleserv_nick: "ScrobbleServ", | |
| 32 | 33 | service_host: "services." |
| 33 | 34 | |
| 34 | 35 | # WebIRC LiveView toggle. When enabled, logged-in users can open |
added
lib/oh_my_ex_oh_ircd/irc/scrobble_serv.ex
+640
−0
| 1 | +defmodule OhMyExOhIrcd.IRC.ScrobbleServ do | |
| 2 | + @moduledoc """ | |
| 3 | + Pseudo-service that announces "now playing" scrobbles into channels. | |
| 4 | + | |
| 5 | + Like `ChanServ`, it lives as a Horde-supervised cluster-wide singleton | |
| 6 | + registered under `{:user, downcased_nick}`, so PRIVMSGs and INVITEs to | |
| 7 | + it from anywhere in the cluster route here. | |
| 8 | + | |
| 9 | + ScrobbleServ is *invited* into a registered channel (`/invite | |
| 10 | + ScrobbleServ #chan` by the founder or a channel op), where it joins as a | |
| 11 | + voiced member — voice is required so a `+m` (moderated) channel doesn't | |
| 12 | + silently drop its announcements. It is configured per-channel via direct | |
| 13 | + messages (`/msg ScrobbleServ HELP`) and via the web UI. | |
| 14 | + | |
| 15 | + Scrobbles arrive out-of-band over an authenticated HTTP endpoint (see | |
| 16 | + `OhMyExOhIrcdWeb.ScrobbleController`), which calls `announce/5`. The | |
| 17 | + service checks the channel config (enabled / allowlist / rate limit), | |
| 18 | + posts the rendered line to the channel, and records it for the web UI. | |
| 19 | + | |
| 20 | + Config and history live in `OhMyExOhIrcd.Scrobbles`. Rate-limit state is | |
| 21 | + in-memory (per `{channel, who}`), so it resets if the singleton restarts | |
| 22 | + — acceptable for a "now playing" bot. | |
| 23 | + """ | |
| 24 | + | |
| 25 | + use GenServer, restart: :transient | |
| 26 | + require Logger | |
| 27 | + | |
| 28 | + alias OhMyExOhIrcd.Cluster.{HordeRegistry, HordeSupervisor} | |
| 29 | + alias OhMyExOhIrcd.{Accounts, Channels, Scrobbles} | |
| 30 | + alias OhMyExOhIrcd.IRC.Channel | |
| 31 | + | |
| 32 | + # Voice rank keeps announcements flowing through `+m`. | |
| 33 | + @join_access :voice | |
| 34 | + @announce_timeout 5_000 | |
| 35 | + @max_line 400 | |
| 36 | + | |
| 37 | + ## Public API | |
| 38 | + | |
| 39 | + @doc "Configured ScrobbleServ nick (default `ScrobbleServ`)." | |
| 40 | + def nick do | |
| 41 | + Application.get_env(:oh_my_ex_oh_ircd, :services, []) | |
| 42 | + |> Keyword.get(:scrobbleserv_nick, "ScrobbleServ") | |
| 43 | + end | |
| 44 | + | |
| 45 | + def host do | |
| 46 | + Application.get_env(:oh_my_ex_oh_ircd, :services, []) | |
| 47 | + |> Keyword.get(:service_host, "services.") | |
| 48 | + end | |
| 49 | + | |
| 50 | + @doc "Ensure ScrobbleServ is running somewhere in the cluster." | |
| 51 | + def ensure_started do | |
| 52 | + key = {:user, String.downcase(nick())} | |
| 53 | + | |
| 54 | + case HordeRegistry.whereis(key) do | |
| 55 | + pid when is_pid(pid) -> | |
| 56 | + {:ok, pid} | |
| 57 | + | |
| 58 | + nil -> | |
| 59 | + spec = %{ | |
| 60 | + id: __MODULE__, | |
| 61 | + start: {__MODULE__, :start_link, []}, | |
| 62 | + restart: :transient | |
| 63 | + } | |
| 64 | + | |
| 65 | + case Horde.DynamicSupervisor.start_child(HordeSupervisor, spec) do | |
| 66 | + {:ok, pid} -> {:ok, pid} | |
| 67 | + {:error, {:already_started, pid}} -> {:ok, pid} | |
| 68 | + other -> other | |
| 69 | + end | |
| 70 | + end | |
| 71 | + end | |
| 72 | + | |
| 73 | + def start_link(_opts \\ []) do | |
| 74 | + GenServer.start_link(__MODULE__, [], | |
| 75 | + name: HordeRegistry.via({:user, String.downcase(nick())}) | |
| 76 | + ) | |
| 77 | + end | |
| 78 | + | |
| 79 | + @doc """ | |
| 80 | + Announce a scrobble into `channel`. Returns `:ok` when posted, | |
| 81 | + `{:ignored, reason}` when suppressed by config (`:disabled`, | |
| 82 | + `:not_allowed`, `:rate_limited`), `{:error, :not_joined}` when the | |
| 83 | + service isn't set up in that channel, or `{:error, :unavailable}` if the | |
| 84 | + singleton can't be reached / times out. | |
| 85 | + """ | |
| 86 | + def announce(channel, who, artist, track, album \\ nil) do | |
| 87 | + case HordeRegistry.whereis({:user, String.downcase(nick())}) do | |
| 88 | + pid when is_pid(pid) -> | |
| 89 | + try do | |
| 90 | + GenServer.call(pid, {:announce, channel, who, artist, track, album}, @announce_timeout) | |
| 91 | + catch | |
| 92 | + _, _ -> {:error, :unavailable} | |
| 93 | + end | |
| 94 | + | |
| 95 | + nil -> | |
| 96 | + {:error, :unavailable} | |
| 97 | + end | |
| 98 | + end | |
| 99 | + | |
| 100 | + ## Server | |
| 101 | + | |
| 102 | + @impl true | |
| 103 | + def init(_) do | |
| 104 | + Logger.info("ScrobbleServ online as #{nick()}") | |
| 105 | + {:ok, %{rate: %{}}, {:continue, :rejoin}} | |
| 106 | + end | |
| 107 | + | |
| 108 | + @impl true | |
| 109 | + def handle_continue(:rejoin, state) do | |
| 110 | + for channel <- safe_joined_channels() do | |
| 111 | + join_channel(channel) | |
| 112 | + end | |
| 113 | + | |
| 114 | + {:noreply, state} | |
| 115 | + end | |
| 116 | + | |
| 117 | + # WHOIS support. | |
| 118 | + @impl true | |
| 119 | + def handle_call(:whois_info, _from, state) do | |
| 120 | + info = %{ | |
| 121 | + nick: nick(), | |
| 122 | + user: "service", | |
| 123 | + host: host(), | |
| 124 | + realname: "Scrobble Services", | |
| 125 | + channels: [], | |
| 126 | + idle: 0, | |
| 127 | + signon: 0, | |
| 128 | + away: nil, | |
| 129 | + account: nil | |
| 130 | + } | |
| 131 | + | |
| 132 | + {:reply, info, state} | |
| 133 | + end | |
| 134 | + | |
| 135 | + def handle_call({:announce, channel, who, artist, track, album}, _from, state) do | |
| 136 | + case Scrobbles.get_config(channel) do | |
| 137 | + nil -> | |
| 138 | + {:reply, {:error, :not_joined}, state} | |
| 139 | + | |
| 140 | + %{joined: false} -> | |
| 141 | + {:reply, {:error, :not_joined}, state} | |
| 142 | + | |
| 143 | + %{enabled: false} -> | |
| 144 | + {:reply, {:ignored, :disabled}, state} | |
| 145 | + | |
| 146 | + cfg -> | |
| 147 | + do_announce(state, channel, cfg, who, artist, track, album) | |
| 148 | + end | |
| 149 | + end | |
| 150 | + | |
| 151 | + ## Invites / kicks | |
| 152 | + | |
| 153 | + @impl true | |
| 154 | + def handle_info({:irc_invite, inviter, channel}, state) do | |
| 155 | + case require_op(channel, inviter) do | |
| 156 | + {:ok, _user} -> | |
| 157 | + case Scrobbles.ensure_config(channel) do | |
| 158 | + {:ok, _cfg} -> | |
| 159 | + Scrobbles.set_joined(channel, true) | |
| 160 | + join_channel(channel) | |
| 161 | + | |
| 162 | + reply(inviter, [ | |
| 163 | + "Joined #{channel}. Announcements are ON.", | |
| 164 | + "Mint an API token with: TOKEN NEW #{channel}", | |
| 165 | + "See all commands with: HELP" | |
| 166 | + ]) | |
| 167 | + | |
| 168 | + {:error, :not_registered} -> | |
| 169 | + reply(inviter, "#{channel} must be registered with ChanServ before I can join.") | |
| 170 | + | |
| 171 | + {:error, _cs} -> | |
| 172 | + reply(inviter, "Could not set up scrobbling on #{channel}.") | |
| 173 | + end | |
| 174 | + | |
| 175 | + :stop -> | |
| 176 | + :ok | |
| 177 | + end | |
| 178 | + | |
| 179 | + {:noreply, state} | |
| 180 | + end | |
| 181 | + | |
| 182 | + def handle_info({:irc_kicked, channel}, state) do | |
| 183 | + Scrobbles.set_joined(channel, false) | |
| 184 | + {:noreply, %{state | rate: drop_channel_rate(state.rate, channel)}} | |
| 185 | + end | |
| 186 | + | |
| 187 | + def handle_info({:irc_privmsg, %{sender: sender, text: text}}, state) do | |
| 188 | + sender_nick = extract_nick(sender) | |
| 189 | + Logger.debug("ScrobbleServ <- #{sender_nick}: #{text}") | |
| 190 | + state = handle_command(text, sender_nick, state) | |
| 191 | + {:noreply, state} | |
| 192 | + end | |
| 193 | + | |
| 194 | + def handle_info(_, state), do: {:noreply, state} | |
| 195 | + | |
| 196 | + ## Announce internals | |
| 197 | + | |
| 198 | + defp do_announce(state, channel, cfg, who, artist, track, album) do | |
| 199 | + who_norm = normalize_who(who) | |
| 200 | + | |
| 201 | + cond do | |
| 202 | + cfg.restrict_to_allowlist and who_norm not in cfg.allowlist -> | |
| 203 | + {:reply, {:ignored, :not_allowed}, state} | |
| 204 | + | |
| 205 | + rate_limited?(state.rate, channel, who_norm, cfg.rate_limit_seconds) -> | |
| 206 | + {:reply, {:ignored, :rate_limited}, state} | |
| 207 | + | |
| 208 | + true -> | |
| 209 | + ensure_member(channel) | |
| 210 | + text = render(cfg.format, who, artist, track, album) | |
| 211 | + Channel.privmsg(channel, service_prefix(), text) | |
| 212 | + | |
| 213 | + Scrobbles.record_scrobble(cfg.channel_id, %{ | |
| 214 | + who: clip(who, 64), | |
| 215 | + artist: clip(artist, 256), | |
| 216 | + track: clip(track, 256), | |
| 217 | + album: album && clip(album, 256) | |
| 218 | + }) | |
| 219 | + | |
| 220 | + {:reply, :ok, %{state | rate: mark_rate(state.rate, channel, who_norm)}} | |
| 221 | + end | |
| 222 | + end | |
| 223 | + | |
| 224 | + defp render(format, who, artist, track, album) do | |
| 225 | + format | |
| 226 | + |> String.replace("%who%", strip(who)) | |
| 227 | + |> String.replace("%artist%", strip(artist)) | |
| 228 | + |> String.replace("%track%", strip(track)) | |
| 229 | + |> String.replace("%album%", strip(album || "")) | |
| 230 | + |> clip(@max_line) | |
| 231 | + end | |
| 232 | + | |
| 233 | + # Re-join if the Channel singleton restarted out from under us. | |
| 234 | + defp ensure_member(channel) do | |
| 235 | + case Channel.member_info(channel, nick()) do | |
| 236 | + %{access: _} -> :ok | |
| 237 | + _ -> join_channel(channel) | |
| 238 | + end | |
| 239 | + catch | |
| 240 | + _, _ -> :ok | |
| 241 | + end | |
| 242 | + | |
| 243 | + defp join_channel(channel) do | |
| 244 | + Channel.join(channel, nick(), self(), nil, service_prefix()) | |
| 245 | + Channel.service_set_access(channel, nick(), @join_access, nick()) | |
| 246 | + :ok | |
| 247 | + catch | |
| 248 | + _, _ -> :ok | |
| 249 | + end | |
| 250 | + | |
| 251 | + ## Rate limiting (in-memory, per {downcased channel, downcased who}) | |
| 252 | + | |
| 253 | + defp rate_limited?(_rate, _channel, _who, seconds) when seconds <= 0, do: false | |
| 254 | + | |
| 255 | + defp rate_limited?(rate, channel, who, seconds) do | |
| 256 | + case Map.get(rate, {downcase(channel), who}) do | |
| 257 | + nil -> false | |
| 258 | + last -> now() - last < seconds | |
| 259 | + end | |
| 260 | + end | |
| 261 | + | |
| 262 | + defp mark_rate(rate, channel, who), do: Map.put(rate, {downcase(channel), who}, now()) | |
| 263 | + | |
| 264 | + defp drop_channel_rate(rate, channel) do | |
| 265 | + down = downcase(channel) | |
| 266 | + rate |> Enum.reject(fn {{c, _w}, _} -> c == down end) |> Map.new() | |
| 267 | + end | |
| 268 | + | |
| 269 | + ## Command dispatch | |
| 270 | + | |
| 271 | + defp handle_command(text, who, state) do | |
| 272 | + case String.split(text, ~r/\s+/, trim: true) do | |
| 273 | + [] -> state | |
| 274 | + [cmd | args] -> do_command(String.upcase(cmd), args, who, state) | |
| 275 | + end | |
| 276 | + end | |
| 277 | + | |
| 278 | + defp do_command("HELP", _args, who, state) do | |
| 279 | + reply(who, [ | |
| 280 | + "ScrobbleServ announces \"now playing\" scrobbles into channels.", | |
| 281 | + "Invite me with /invite ScrobbleServ #channel (founder/op of a registered channel).", | |
| 282 | + "Commands (all but HELP require you to be founder/op of the channel):", | |
| 283 | + " INFO #chan Show current configuration.", | |
| 284 | + " ENABLE / DISABLE #chan Turn announcements on/off.", | |
| 285 | + " FORMAT #chan <template> Set the line. Tokens: %who% %artist% %track% %album%.", | |
| 286 | + " RATELIMIT #chan <seconds> Min seconds between a who's announcements (0 = off).", | |
| 287 | + " RESTRICT #chan ON|OFF Only announce allowlisted who-names.", | |
| 288 | + " ALLOW / DENY #chan <who> Add/remove a who-name from the allowlist.", | |
| 289 | + " ALLOWLIST #chan Show the allowlist.", | |
| 290 | + " TOKEN NEW #chan <who> [label] Mint an API token bound to you (shown once).", | |
| 291 | + " TOKEN LIST #chan List tokens (who + label).", | |
| 292 | + " TOKEN REVOKE #chan <id> Revoke a token by id.", | |
| 293 | + " PART #chan Leave the channel and stop announcing." | |
| 294 | + ]) | |
| 295 | + | |
| 296 | + state | |
| 297 | + end | |
| 298 | + | |
| 299 | + defp do_command("INFO", [channel], who, state) do | |
| 300 | + with_op(channel, who, state, fn -> | |
| 301 | + case Scrobbles.get_config(channel) do | |
| 302 | + nil -> | |
| 303 | + reply(who, "#{channel} has no scrobble config. Invite me first.") | |
| 304 | + | |
| 305 | + cfg -> | |
| 306 | + reply(who, [ | |
| 307 | + "#{channel} scrobble config:", | |
| 308 | + " Joined: #{cfg.joined}", | |
| 309 | + " Enabled: #{cfg.enabled}", | |
| 310 | + " Format: #{cfg.format}", | |
| 311 | + " Ratelimit: #{cfg.rate_limit_seconds}s", | |
| 312 | + " Restrict: #{cfg.restrict_to_allowlist} (#{length(cfg.allowlist)} allowed)" | |
| 313 | + ]) | |
| 314 | + end | |
| 315 | + end) | |
| 316 | + end | |
| 317 | + | |
| 318 | + defp do_command("ENABLE", [channel], who, state) do | |
| 319 | + with_op(channel, who, state, fn -> | |
| 320 | + Scrobbles.set_enabled(channel, true) | |
| 321 | + reply(who, "Announcements ENABLED on #{channel}.") | |
| 322 | + end) | |
| 323 | + end | |
| 324 | + | |
| 325 | + defp do_command("DISABLE", [channel], who, state) do | |
| 326 | + with_op(channel, who, state, fn -> | |
| 327 | + Scrobbles.set_enabled(channel, false) | |
| 328 | + reply(who, "Announcements DISABLED on #{channel}.") | |
| 329 | + end) | |
| 330 | + end | |
| 331 | + | |
| 332 | + defp do_command("FORMAT", [channel | rest], who, state) when rest != [] do | |
| 333 | + with_op(channel, who, state, fn -> | |
| 334 | + template = Enum.join(rest, " ") | |
| 335 | + | |
| 336 | + case Scrobbles.set_format(channel, template) do | |
| 337 | + {:ok, cfg} -> reply(who, "Format for #{channel} set to: #{cfg.format}") | |
| 338 | + _ -> reply(who, "Could not set format.") | |
| 339 | + end | |
| 340 | + end) | |
| 341 | + end | |
| 342 | + | |
| 343 | + defp do_command("RATELIMIT", [channel, seconds], who, state) do | |
| 344 | + with_op(channel, who, state, fn -> | |
| 345 | + case Integer.parse(seconds) do | |
| 346 | + {n, ""} when n >= 0 -> | |
| 347 | + case Scrobbles.set_rate_limit(channel, n) do | |
| 348 | + {:ok, _} -> reply(who, "Rate limit for #{channel} set to #{n}s.") | |
| 349 | + _ -> reply(who, "Could not set rate limit.") | |
| 350 | + end | |
| 351 | + | |
| 352 | + _ -> | |
| 353 | + reply(who, "Usage: RATELIMIT #{channel} <seconds>") | |
| 354 | + end | |
| 355 | + end) | |
| 356 | + end | |
| 357 | + | |
| 358 | + defp do_command("RESTRICT", [channel, toggle], who, state) do | |
| 359 | + with_op(channel, who, state, fn -> | |
| 360 | + case parse_toggle(toggle) do | |
| 361 | + {:ok, value} -> | |
| 362 | + Scrobbles.set_restrict(channel, value) | |
| 363 | + reply(who, "Allowlist enforcement on #{channel}: #{if value, do: "ON", else: "OFF"}.") | |
| 364 | + | |
| 365 | + :error -> | |
| 366 | + reply(who, "Usage: RESTRICT #{channel} ON|OFF") | |
| 367 | + end | |
| 368 | + end) | |
| 369 | + end | |
| 370 | + | |
| 371 | + defp do_command("ALLOW", [channel, target], who, state) do | |
| 372 | + with_op(channel, who, state, fn -> | |
| 373 | + Scrobbles.add_allow(channel, target) | |
| 374 | + reply(who, "Added #{target} to the #{channel} allowlist.") | |
| 375 | + end) | |
| 376 | + end | |
| 377 | + | |
| 378 | + defp do_command("DENY", [channel, target], who, state) do | |
| 379 | + with_op(channel, who, state, fn -> | |
| 380 | + Scrobbles.remove_allow(channel, target) | |
| 381 | + reply(who, "Removed #{target} from the #{channel} allowlist.") | |
| 382 | + end) | |
| 383 | + end | |
| 384 | + | |
| 385 | + defp do_command("ALLOWLIST", [channel], who, state) do | |
| 386 | + with_op(channel, who, state, fn -> | |
| 387 | + case Scrobbles.get_config(channel) do | |
| 388 | + %{allowlist: [_ | _] = list} -> | |
| 389 | + reply(who, "#{channel} allowlist: " <> Enum.join(list, ", ")) | |
| 390 | + | |
| 391 | + _ -> | |
| 392 | + reply(who, "#{channel} allowlist is empty.") | |
| 393 | + end | |
| 394 | + end) | |
| 395 | + end | |
| 396 | + | |
| 397 | + defp do_command("TOKEN", ["NEW", channel, who_name | rest], who, state) do | |
| 398 | + with_op(channel, who, state, fn -> | |
| 399 | + label = if rest == [], do: nil, else: Enum.join(rest, " ") | |
| 400 | + | |
| 401 | + case op_user(channel, who) do | |
| 402 | + nil -> | |
| 403 | + reply(who, "Could not mint a token.") | |
| 404 | + | |
| 405 | + user -> | |
| 406 | + case Scrobbles.mint_token(channel, user, who_name, label) do | |
| 407 | + {:ok, display, _token} -> | |
| 408 | + reply(who, [ | |
| 409 | + "New scrobble token for #{channel} as \"#{who_name}\" (shown once — store it now):", | |
| 410 | + " #{display}", | |
| 411 | + "POST it as `Authorization: Bearer <token>` to /api/scrobble." | |
| 412 | + ]) | |
| 413 | + | |
| 414 | + _ -> | |
| 415 | + reply(who, "Could not mint a token.") | |
| 416 | + end | |
| 417 | + end | |
| 418 | + end) | |
| 419 | + end | |
| 420 | + | |
| 421 | + defp do_command("TOKEN", ["NEW" | _], who, state) do | |
| 422 | + reply(who, "Usage: TOKEN NEW #channel <who> [label]") | |
| 423 | + state | |
| 424 | + end | |
| 425 | + | |
| 426 | + defp do_command("TOKEN", ["LIST", channel], who, state) do | |
| 427 | + with_op(channel, who, state, fn -> | |
| 428 | + case Scrobbles.list_tokens(channel) do | |
| 429 | + [] -> | |
| 430 | + reply(who, "#{channel} has no active tokens.") | |
| 431 | + | |
| 432 | + tokens -> | |
| 433 | + reply(who, "Active tokens for #{channel}:") | |
| 434 | + | |
| 435 | + Enum.each(tokens, fn t -> | |
| 436 | + used = if t.last_used_at, do: DateTime.to_iso8601(t.last_used_at), else: "never" | |
| 437 | + label = if t.label, do: " (#{t.label})", else: "" | |
| 438 | + reply(who, " ##{t.id} as #{t.who}#{label} — last used: #{used}") | |
| 439 | + end) | |
| 440 | + end | |
| 441 | + end) | |
| 442 | + end | |
| 443 | + | |
| 444 | + defp do_command("TOKEN", ["REVOKE", channel, id], who, state) do | |
| 445 | + with_op(channel, who, state, fn -> | |
| 446 | + case Integer.parse(id) do | |
| 447 | + {token_id, ""} -> | |
| 448 | + Scrobbles.revoke_token(channel, token_id) | |
| 449 | + reply(who, "Token ##{token_id} on #{channel} revoked (if it existed).") | |
| 450 | + | |
| 451 | + _ -> | |
| 452 | + reply(who, "Usage: TOKEN REVOKE #{channel} <id>") | |
| 453 | + end | |
| 454 | + end) | |
| 455 | + end | |
| 456 | + | |
| 457 | + defp do_command("TOKEN", _args, who, state) do | |
| 458 | + reply(who, "Usage: TOKEN NEW|LIST|REVOKE #channel [args]") | |
| 459 | + state | |
| 460 | + end | |
| 461 | + | |
| 462 | + defp do_command("PART", [channel], who, state) do | |
| 463 | + case require_op(channel, who) do | |
| 464 | + {:ok, _user} -> | |
| 465 | + Scrobbles.set_joined(channel, false) | |
| 466 | + Channel.part(channel, nick(), "Scrobbling disabled") | |
| 467 | + reply(who, "Left #{channel}. I'll stop announcing there.") | |
| 468 | + %{state | rate: drop_channel_rate(state.rate, channel)} | |
| 469 | + | |
| 470 | + :stop -> | |
| 471 | + state | |
| 472 | + end | |
| 473 | + end | |
| 474 | + | |
| 475 | + defp do_command(other, _args, who, state) do | |
| 476 | + reply(who, "Unknown command: #{other}. Try HELP.") | |
| 477 | + state | |
| 478 | + end | |
| 479 | + | |
| 480 | + ## Authorization | |
| 481 | + | |
| 482 | + # Run `fun` only if `who` is founder/op of the registered `channel`. | |
| 483 | + # Always returns `state` (commands don't mutate it except PART/rate). | |
| 484 | + defp with_op(channel, who, state, fun) do | |
| 485 | + case require_op(channel, who) do | |
| 486 | + {:ok, _user} -> fun.() | |
| 487 | + :stop -> :ok | |
| 488 | + end | |
| 489 | + | |
| 490 | + state | |
| 491 | + end | |
| 492 | + | |
| 493 | + defp require_op(channel, who) do | |
| 494 | + case Channels.get(channel) do | |
| 495 | + nil -> | |
| 496 | + reply(who, "#{channel} is not registered. Register it with ChanServ first.") | |
| 497 | + :stop | |
| 498 | + | |
| 499 | + rec -> | |
| 500 | + with {:ok, user} <- require_account(who) do | |
| 501 | + if op?(user, who, channel, rec) do | |
| 502 | + {:ok, user} | |
| 503 | + else | |
| 504 | + reply(who, "Only the channel founder or an op may do that.") | |
| 505 | + :stop | |
| 506 | + end | |
| 507 | + end | |
| 508 | + end | |
| 509 | + end | |
| 510 | + | |
| 511 | + defp require_account(who) do | |
| 512 | + case account_for(who) do | |
| 513 | + {:ok, user} -> | |
| 514 | + {:ok, user} | |
| 515 | + | |
| 516 | + :error -> | |
| 517 | + reply(who, "You must be logged in. Connect with SASL or PASS using your account.") | |
| 518 | + :stop | |
| 519 | + end | |
| 520 | + end | |
| 521 | + | |
| 522 | + defp op?(user, who, channel, rec) do | |
| 523 | + cond do | |
| 524 | + rec.founder_user_id == user.id -> true | |
| 525 | + persisted_op?(user, channel) -> true | |
| 526 | + live_op?(who, channel) -> true | |
| 527 | + true -> false | |
| 528 | + end | |
| 529 | + end | |
| 530 | + | |
| 531 | + defp persisted_op?(user, channel) do | |
| 532 | + down = downcase(channel) | |
| 533 | + | |
| 534 | + Channels.list_for_user(user) | |
| 535 | + |> Enum.any?(fn %{record: r, role: role} -> | |
| 536 | + r.name == down and role in [:founder, :admin, :op] | |
| 537 | + end) | |
| 538 | + end | |
| 539 | + | |
| 540 | + defp live_op?(who, channel) do | |
| 541 | + case Channel.member_info(channel, who) do | |
| 542 | + %{access: a} -> Channel.rank(a) >= Channel.rank(:op) | |
| 543 | + _ -> false | |
| 544 | + end | |
| 545 | + catch | |
| 546 | + _, _ -> false | |
| 547 | + end | |
| 548 | + | |
| 549 | + defp op_user(channel, who) do | |
| 550 | + case Channels.get(channel) do | |
| 551 | + nil -> | |
| 552 | + nil | |
| 553 | + | |
| 554 | + _ -> | |
| 555 | + case account_for(who) do | |
| 556 | + {:ok, user} -> user | |
| 557 | + _ -> nil | |
| 558 | + end | |
| 559 | + end | |
| 560 | + end | |
| 561 | + | |
| 562 | + defp account_for(who) do | |
| 563 | + case HordeRegistry.whereis({:user, String.downcase(who)}) do | |
| 564 | + pid when is_pid(pid) -> | |
| 565 | + try do | |
| 566 | + info = GenServer.call(pid, :whois_info, 2_000) | |
| 567 | + | |
| 568 | + case info[:account] && Accounts.get_user_by_email(info.account) do | |
| 569 | + %_{} = user -> {:ok, user} | |
| 570 | + _ -> :error | |
| 571 | + end | |
| 572 | + catch | |
| 573 | + _, _ -> :error | |
| 574 | + end | |
| 575 | + | |
| 576 | + nil -> | |
| 577 | + :error | |
| 578 | + end | |
| 579 | + end | |
| 580 | + | |
| 581 | + ## Reply helpers | |
| 582 | + | |
| 583 | + defp reply(target_nick, lines) when is_list(lines), | |
| 584 | + do: Enum.each(lines, &reply(target_nick, &1)) | |
| 585 | + | |
| 586 | + defp reply(target_nick, line) when is_binary(line) do | |
| 587 | + case HordeRegistry.whereis({:user, String.downcase(target_nick)}) do | |
| 588 | + pid when is_pid(pid) -> | |
| 589 | + send( | |
| 590 | + pid, | |
| 591 | + {:irc_privmsg, | |
| 592 | + %{ | |
| 593 | + sender: nick(), | |
| 594 | + target: target_nick, | |
| 595 | + text: line, | |
| 596 | + kind: :notice | |
| 597 | + }} | |
| 598 | + ) | |
| 599 | + | |
| 600 | + nil -> | |
| 601 | + :ok | |
| 602 | + end | |
| 603 | + end | |
| 604 | + | |
| 605 | + ## Misc | |
| 606 | + | |
| 607 | + defp safe_joined_channels do | |
| 608 | + Scrobbles.list_joined_channels() | |
| 609 | + rescue | |
| 610 | + _ -> [] | |
| 611 | + end | |
| 612 | + | |
| 613 | + defp parse_toggle(t) do | |
| 614 | + case String.upcase(t) do | |
| 615 | + v when v in ["ON", "TRUE", "1", "YES"] -> {:ok, true} | |
| 616 | + v when v in ["OFF", "FALSE", "0", "NO"] -> {:ok, false} | |
| 617 | + _ -> :error | |
| 618 | + end | |
| 619 | + end | |
| 620 | + | |
| 621 | + defp extract_nick(prefix) when is_binary(prefix) do | |
| 622 | + case String.split(prefix, "!", parts: 2) do | |
| 623 | + [n | _] -> n | |
| 624 | + end | |
| 625 | + end | |
| 626 | + | |
| 627 | + defp normalize_who(who), do: who |> String.trim() |> String.downcase() | |
| 628 | + | |
| 629 | + defp strip(s) when is_binary(s), do: String.replace(s, ~r/[\x00-\x1f]/, "") | |
| 630 | + defp strip(_), do: "" | |
| 631 | + | |
| 632 | + defp clip(nil, _), do: nil | |
| 633 | + defp clip(s, max) when is_binary(s), do: String.slice(s, 0, max) | |
| 634 | + | |
| 635 | + defp downcase(s) when is_binary(s), do: String.downcase(s) | |
| 636 | + | |
| 637 | + defp now, do: System.system_time(:second) | |
| 638 | + | |
| 639 | + defp service_prefix, do: "#{nick()}!service@#{host()}" | |
| 640 | +end |
modified
lib/oh_my_ex_oh_ircd/irc/services.ex
+7
−2
| 1 | 1 | defmodule OhMyExOhIrcd.IRC.Services do |
| 2 | 2 | @moduledoc """ |
| 3 | 3 | Boot-time starter that ensures the cluster-wide pseudo-services |
| 4 | − (currently just ChanServ) are running. Sits in the supervision tree | |
| 4 | + (ChanServ, ScrobbleServ) are running. Sits in the supervision tree | |
| 5 | 5 | *after* Horde so it can talk to the cluster registry. |
| 6 | 6 | """ |
| 7 | 7 | |
| 8 | 8 | use GenServer |
| 9 | 9 | require Logger |
| 10 | 10 | |
| 11 | − alias OhMyExOhIrcd.IRC.ChanServ | |
| 11 | + alias OhMyExOhIrcd.IRC.{ChanServ, ScrobbleServ} | |
| 12 | 12 | |
| 13 | 13 | def start_link(_), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) |
| 14 | 14 |
| 24 | 24 | other -> Logger.warning("Services: ChanServ failed to start: #{inspect(other)}") |
| 25 | 25 | end |
| 26 | 26 | |
| 27 | + case ScrobbleServ.ensure_started() do | |
| 28 | + {:ok, _pid} -> Logger.debug("Services: ScrobbleServ ready") | |
| 29 | + other -> Logger.warning("Services: ScrobbleServ failed to start: #{inspect(other)}") | |
| 30 | + end | |
| 31 | + | |
| 27 | 32 | {:noreply, state} |
| 28 | 33 | end |
| 29 | 34 | end |
modified
lib/oh_my_ex_oh_ircd/irc/stats.ex
+5
−1
| 38 | 38 | """ |
| 39 | 39 | @spec users() :: [map()] |
| 40 | 40 | def users do |
| 41 | − service_nicks = MapSet.new([String.downcase(OhMyExOhIrcd.IRC.ChanServ.nick())]) | |
| 41 | + service_nicks = | |
| 42 | + MapSet.new([ | |
| 43 | + String.downcase(OhMyExOhIrcd.IRC.ChanServ.nick()), | |
| 44 | + String.downcase(OhMyExOhIrcd.IRC.ScrobbleServ.nick()) | |
| 45 | + ]) | |
| 42 | 46 | |
| 43 | 47 | Horde.Registry.select(HordeRegistry, [ |
| 44 | 48 | {{{:user, :"$1"}, :"$2", :_}, [], [{{:"$1", :"$2"}}]} |
added
lib/oh_my_ex_oh_ircd/scrobbles.ex
+215
−0
| 1 | +defmodule OhMyExOhIrcd.Scrobbles do | |
| 2 | + @moduledoc """ | |
| 3 | + Context for ScrobbleServ persistence: per-channel config, per-channel | |
| 4 | + API tokens, and the append-only scrobble history shown in the web UI. | |
| 5 | + | |
| 6 | + All channel lookups go through `OhMyExOhIrcd.Channels.get/1`, so only | |
| 7 | + ChanServ-registered channels can be configured (the FK to `channels` | |
| 8 | + enforces this at the DB layer, and `on_delete: :delete_all` means | |
| 9 | + dropping a registration cascades away its config, tokens and history). | |
| 10 | + """ | |
| 11 | + | |
| 12 | + import Ecto.Query, warn: false | |
| 13 | + | |
| 14 | + alias OhMyExOhIrcd.Repo | |
| 15 | + alias OhMyExOhIrcd.Accounts.User | |
| 16 | + alias OhMyExOhIrcd.Channels | |
| 17 | + alias OhMyExOhIrcd.Channels.ChannelRecord | |
| 18 | + alias OhMyExOhIrcd.Scrobbles.{ChannelConfig, Scrobble, Token} | |
| 19 | + | |
| 20 | + ## Config | |
| 21 | + | |
| 22 | + @doc "Fetch the config for a channel by name. Returns nil if absent." | |
| 23 | + def get_config(channel_name) when is_binary(channel_name) do | |
| 24 | + case Channels.get(channel_name) do | |
| 25 | + nil -> nil | |
| 26 | + %ChannelRecord{id: id} -> Repo.get_by(ChannelConfig, channel_id: id) | |
| 27 | + end | |
| 28 | + end | |
| 29 | + | |
| 30 | + @doc """ | |
| 31 | + Fetch the config for a registered channel, creating it (enabled) if it | |
| 32 | + doesn't exist yet. Returns `{:ok, config}` or `{:error, :not_registered}`. | |
| 33 | + """ | |
| 34 | + def ensure_config(channel_name, attrs \\ %{}) when is_binary(channel_name) do | |
| 35 | + case Channels.get(channel_name) do | |
| 36 | + nil -> | |
| 37 | + {:error, :not_registered} | |
| 38 | + | |
| 39 | + %ChannelRecord{id: id} -> | |
| 40 | + case Repo.get_by(ChannelConfig, channel_id: id) do | |
| 41 | + nil -> | |
| 42 | + %ChannelConfig{} | |
| 43 | + |> ChannelConfig.changeset(Map.merge(%{channel_id: id}, attrs)) | |
| 44 | + |> Repo.insert() | |
| 45 | + | |
| 46 | + %ChannelConfig{} = cfg -> | |
| 47 | + {:ok, cfg} | |
| 48 | + end | |
| 49 | + end | |
| 50 | + end | |
| 51 | + | |
| 52 | + @doc """ | |
| 53 | + Update config fields for a registered channel, creating the row first if | |
| 54 | + needed. Returns `{:ok, config}`, `{:error, changeset}`, or | |
| 55 | + `{:error, :not_registered}`. | |
| 56 | + """ | |
| 57 | + def update_config(channel_name, attrs) when is_binary(channel_name) do | |
| 58 | + with {:ok, cfg} <- ensure_config(channel_name) do | |
| 59 | + cfg | |
| 60 | + |> ChannelConfig.changeset(attrs) | |
| 61 | + |> Repo.update() | |
| 62 | + end | |
| 63 | + end | |
| 64 | + | |
| 65 | + def set_enabled(channel_name, value), do: update_config(channel_name, %{enabled: !!value}) | |
| 66 | + def set_joined(channel_name, value), do: update_config(channel_name, %{joined: !!value}) | |
| 67 | + def set_format(channel_name, format), do: update_config(channel_name, %{format: format}) | |
| 68 | + | |
| 69 | + def set_restrict(channel_name, value), | |
| 70 | + do: update_config(channel_name, %{restrict_to_allowlist: !!value}) | |
| 71 | + | |
| 72 | + def set_rate_limit(channel_name, seconds), | |
| 73 | + do: update_config(channel_name, %{rate_limit_seconds: seconds}) | |
| 74 | + | |
| 75 | + @doc "Add a `who` display name to the allowlist (stored downcased, deduped)." | |
| 76 | + def add_allow(channel_name, who) when is_binary(who) do | |
| 77 | + who = normalize_who(who) | |
| 78 | + | |
| 79 | + with {:ok, cfg} <- ensure_config(channel_name) do | |
| 80 | + list = Enum.uniq([who | cfg.allowlist]) | |
| 81 | + update_config(channel_name, %{allowlist: list}) | |
| 82 | + end | |
| 83 | + end | |
| 84 | + | |
| 85 | + @doc "Remove a `who` display name from the allowlist." | |
| 86 | + def remove_allow(channel_name, who) when is_binary(who) do | |
| 87 | + who = normalize_who(who) | |
| 88 | + | |
| 89 | + with {:ok, cfg} <- ensure_config(channel_name) do | |
| 90 | + list = Enum.reject(cfg.allowlist, &(&1 == who)) | |
| 91 | + update_config(channel_name, %{allowlist: list}) | |
| 92 | + end | |
| 93 | + end | |
| 94 | + | |
| 95 | + @doc "Channel names where ScrobbleServ should currently be present." | |
| 96 | + def list_joined_channels do | |
| 97 | + Repo.all( | |
| 98 | + from c in ChannelConfig, | |
| 99 | + where: c.joined == true, | |
| 100 | + join: ch in ChannelRecord, | |
| 101 | + on: ch.id == c.channel_id, | |
| 102 | + select: ch.name | |
| 103 | + ) | |
| 104 | + end | |
| 105 | + | |
| 106 | + ## Tokens | |
| 107 | + | |
| 108 | + @doc """ | |
| 109 | + Mint a token for a registered channel, bound to `user` and announcing as | |
| 110 | + `who`. Returns `{:ok, display_token, token}` where `display_token` is | |
| 111 | + shown to the user once. | |
| 112 | + """ | |
| 113 | + def mint_token(channel_name, %User{} = user, who, label \\ nil) | |
| 114 | + when is_binary(channel_name) and is_binary(who) do | |
| 115 | + who = String.trim(who) | |
| 116 | + | |
| 117 | + case Channels.get(channel_name) do | |
| 118 | + _ when who == "" -> | |
| 119 | + {:error, :blank_who} | |
| 120 | + | |
| 121 | + nil -> | |
| 122 | + {:error, :not_registered} | |
| 123 | + | |
| 124 | + %ChannelRecord{id: id} -> | |
| 125 | + {display, struct} = Token.build(id, user.id, who, label) | |
| 126 | + | |
| 127 | + case struct |> Token.changeset(%{}) |> Repo.insert() do | |
| 128 | + {:ok, token} -> {:ok, display, token} | |
| 129 | + {:error, cs} -> {:error, cs} | |
| 130 | + end | |
| 131 | + end | |
| 132 | + end | |
| 133 | + | |
| 134 | + @doc "List (non-revoked) tokens for a channel. Never exposes plaintext." | |
| 135 | + def list_tokens(channel_name) when is_binary(channel_name) do | |
| 136 | + case Channels.get(channel_name) do | |
| 137 | + nil -> | |
| 138 | + [] | |
| 139 | + | |
| 140 | + %ChannelRecord{id: id} -> | |
| 141 | + Repo.all( | |
| 142 | + from t in Token, | |
| 143 | + where: t.channel_id == ^id and is_nil(t.revoked_at), | |
| 144 | + order_by: [desc: t.inserted_at] | |
| 145 | + ) | |
| 146 | + end | |
| 147 | + end | |
| 148 | + | |
| 149 | + @doc "Revoke a token by id, scoped to the channel. Idempotent." | |
| 150 | + def revoke_token(channel_name, token_id) when is_binary(channel_name) do | |
| 151 | + case Channels.get(channel_name) do | |
| 152 | + nil -> | |
| 153 | + :ok | |
| 154 | + | |
| 155 | + %ChannelRecord{id: id} -> | |
| 156 | + from(t in Token, | |
| 157 | + where: t.id == ^token_id and t.channel_id == ^id and is_nil(t.revoked_at) | |
| 158 | + ) | |
| 159 | + |> Repo.update_all(set: [revoked_at: DateTime.utc_now(:second)]) | |
| 160 | + | |
| 161 | + :ok | |
| 162 | + end | |
| 163 | + end | |
| 164 | + | |
| 165 | + @doc """ | |
| 166 | + Verify a presented bearer token. Returns `{:ok, %{channel:, who:, | |
| 167 | + user_id:}}` for a live (non-revoked) token — `who` is the bound display | |
| 168 | + name, NOT anything the client supplied — else `:error`. Bumps | |
| 169 | + `last_used_at` as a side effect on success. | |
| 170 | + """ | |
| 171 | + def verify_token(presented) when is_binary(presented) do | |
| 172 | + with {:ok, hash} <- Token.hash_presented(presented), | |
| 173 | + %Token{} = token <- | |
| 174 | + Repo.one(from t in Token, where: t.token_hash == ^hash and is_nil(t.revoked_at)), | |
| 175 | + %ChannelRecord{} = channel <- Repo.get(ChannelRecord, token.channel_id) do | |
| 176 | + Repo.update_all( | |
| 177 | + from(t in Token, where: t.id == ^token.id), | |
| 178 | + set: [last_used_at: DateTime.utc_now(:second)] | |
| 179 | + ) | |
| 180 | + | |
| 181 | + {:ok, %{channel: channel, who: token.who, user_id: token.user_id}} | |
| 182 | + else | |
| 183 | + _ -> :error | |
| 184 | + end | |
| 185 | + end | |
| 186 | + | |
| 187 | + def verify_token(_), do: :error | |
| 188 | + | |
| 189 | + ## History | |
| 190 | + | |
| 191 | + @doc "Persist an announced scrobble. `attrs` has who/artist/track/album." | |
| 192 | + def record_scrobble(channel_id, attrs) when is_integer(channel_id) do | |
| 193 | + %Scrobble{} | |
| 194 | + |> Scrobble.changeset(Map.put(attrs, :channel_id, channel_id)) | |
| 195 | + |> Repo.insert() | |
| 196 | + end | |
| 197 | + | |
| 198 | + @doc "Most recent `n` scrobbles for a channel, newest first." | |
| 199 | + def recent(channel_name, n \\ 25) when is_binary(channel_name) do | |
| 200 | + case Channels.get(channel_name) do | |
| 201 | + nil -> | |
| 202 | + [] | |
| 203 | + | |
| 204 | + %ChannelRecord{id: id} -> | |
| 205 | + Repo.all( | |
| 206 | + from s in Scrobble, | |
| 207 | + where: s.channel_id == ^id, | |
| 208 | + order_by: [desc: s.inserted_at, desc: s.id], | |
| 209 | + limit: ^n | |
| 210 | + ) | |
| 211 | + end | |
| 212 | + end | |
| 213 | + | |
| 214 | + defp normalize_who(who), do: who |> String.trim() |> String.downcase() | |
| 215 | +end |
added
lib/oh_my_ex_oh_ircd/scrobbles/channel_config.ex
+63
−0
Click to load diff…
added
lib/oh_my_ex_oh_ircd/scrobbles/scrobble.ex
+33
−0
Click to load diff…
added
lib/oh_my_ex_oh_ircd/scrobbles/token.ex
+80
−0
Click to load diff…
added
lib/oh_my_ex_oh_ircd_web/controllers/scrobble_controller.ex
+94
−0
Click to load diff…
modified
lib/oh_my_ex_oh_ircd_web/live/user_live/channels.ex
+9
−0
Click to load diff…
added
lib/oh_my_ex_oh_ircd_web/live/user_live/scrobble.ex
+316
−0
Click to load diff…
added
lib/oh_my_ex_oh_ircd_web/plugs/scrobble_auth.ex
+31
−0
Click to load diff…
modified
lib/oh_my_ex_oh_ircd_web/router.ex
+12
−4
Click to load diff…
added
priv/repo/migrations/20260526000000_create_scrobble_channel_configs.exs
+22
−0
Click to load diff…
added
priv/repo/migrations/20260527000000_create_scrobble_tokens.exs
+24
−0
Click to load diff…
added
priv/repo/migrations/20260528000000_create_scrobbles.exs
+17
−0
Click to load diff…
added
test/oh_my_ex_oh_ircd/irc/scrobble_serv_test.exs
+138
−0
Click to load diff…
added
test/oh_my_ex_oh_ircd/scrobbles_test.exs
+120
−0
Click to load diff…
added
test/oh_my_ex_oh_ircd_web/controllers/scrobble_controller_test.exs
+96
−0
Click to load diff…
added
test/oh_my_ex_oh_ircd_web/live/user_live/scrobble_test.exs
+57
−0
Click to load diff…
Parents: 3c243c4