diff --git a/config/test.exs b/config/test.exs index 877ef63..4249815 100644 --- a/config/test.exs +++ b/config/test.exs @@ -3,6 +3,7 @@ import Config # Don't auto-start the RecordStore in tests — each test starts its own. config :egghead, :start_record_store, false config :egghead, :start_web, false +config :egghead, :start_irc, false # Quiet logs in tests — only warnings and errors config :logger, level: :warning diff --git a/lib/egghead/application.ex b/lib/egghead/application.ex index 50eb146..54bf374 100644 --- a/lib/egghead/application.ex +++ b/lib/egghead/application.ex @@ -5,20 +5,20 @@ defmodule Egghead.Application do All runtime configuration is loaded here in `apply_config/0`. No runtime.exs. Sources in precedence order: - 1. Environment variables (EGGHEAD_RECORDS, PORT, etc.) + 1. Environment variables (EGGHEAD_RECORDS, PORT, EGGHEAD_IRC_PORT, etc.) 2. Config file (~/.config/egghead/config.yml) 3. Compile-time defaults (config/config.exs) In release mode (Burrito binary), `configure_for_command/1` parses argv BEFORE the supervision tree to determine what to start: - | Command | Record store | Web | Log mode | - |---------|-------------|-----|----------| - | (none) / tui | yes | no | :file | - | serve | yes | yes | :console | - | mcp | yes | no | :silent | - | agent list, llm models, doctor, init | yes | no | :silent | - | --help, --version, config, llm list, logs | no | no | :silent | + | Command | Record store | Web | IRC | Log mode | + |---------|-------------|-----|-----|----------| + | (none) / tui | yes | no | no | :file | + | serve | yes | yes | yes | :console | + | mcp | yes | no | no | :silent | + | agent list, llm models, doctor, init | yes | no | no | :silent | + | --help, --version, config, llm list, logs | no | no | no | :silent | """ use Application @@ -73,7 +73,7 @@ defmodule Egghead.Application do {Registry, keys: :unique, name: Egghead.Doc.Registry}, {Egghead.Doc.Supervisor, []}, Egghead.TUI.MarkdownCache - ] ++ web_children() + ] ++ web_children() ++ irc_children() # Commands that don't need the app (--help, config, etc.) true -> @@ -163,14 +163,17 @@ defmodule Egghead.Application do nil -> # Default = TUI Application.put_env(:egghead, :start_web, false) + Application.put_env(:egghead, :start_irc, false) Application.put_env(:egghead, :log_mode, :file) "tui" -> Application.put_env(:egghead, :start_web, false) + Application.put_env(:egghead, :start_irc, false) Application.put_env(:egghead, :log_mode, :file) _ -> Application.put_env(:egghead, :start_web, false) + Application.put_env(:egghead, :start_irc, false) Application.put_env(:egghead, :log_mode, :silent) end end @@ -239,6 +242,11 @@ defmodule Egghead.Application do Application.put_env(:egghead, Egghead.Web.Endpoint, endpoint_config) {:error, _} -> + # No config file — register a default Config struct so callers + # that read `Application.get_env(:egghead, :config)` (notably + # the IRC supervisor) still see the same default-bearing shape + # as a successfully-loaded config. + Application.put_env(:egghead, :config, %Egghead.Config{}) Application.put_env(:egghead, :records_dir, Path.expand("~/.egghead")) current = Application.get_env(:egghead, Egghead.Web.Endpoint, []) @@ -293,6 +301,34 @@ defmodule Egghead.Application do Keyword.put(current, :http, Keyword.put(http, :ip, {0, 0, 0, 0})) ) end + + # IRC env overrides — mirror PORT / EGGHEAD_BIND / EGGHEAD_WEB but + # name-spaced so they don't collide with web. Mutate the loaded + # `:config` struct in place (the IRC supervisor reads from there). + if System.get_env("EGGHEAD_IRC") == "false" do + Application.put_env(:egghead, :start_irc, false) + end + + if port_str = System.get_env("EGGHEAD_IRC_PORT") do + put_irc(:port, String.to_integer(port_str)) + end + + if System.get_env("EGGHEAD_IRC_BIND") == "0.0.0.0" do + put_irc(:bind, "0.0.0.0") + end + end + + # Update one field of the IRC config map in-place. The IRC supervisor + # reads `Application.get_env(:egghead, :config).irc` at boot. + defp put_irc(key, value) do + case Application.get_env(:egghead, :config) do + %Egghead.Config{} = cfg -> + irc = Map.put(cfg.irc || %{}, key, value) + Application.put_env(:egghead, :config, %{cfg | irc: irc}) + + _ -> + :ok + end end # --- Theme --- @@ -401,6 +437,26 @@ defmodule Egghead.Application do end end + defp irc_children do + if Application.get_env(:egghead, :start_irc, true) do + [{Egghead.IRC.Server, config: irc_config()}] + else + [] + end + end + + # Always returns a map — the Config struct's `:irc` field defaults + # to a populated map, and the no-config-file branch in apply_config/0 + # registers a default `%Egghead.Config{}`. If a downstream caller + # somehow blanked the field, fall back to the same defaults so the + # IRC supervisor never sees nil. + defp irc_config do + case Application.get_env(:egghead, :config) do + %{irc: cfg} when is_map(cfg) -> cfg + _ -> %{port: 6667, bind: "127.0.0.1", hostname: nil, password: nil} + end + end + defp release_mode? do Burrito.Util.running_standalone?() end diff --git a/lib/egghead/cli/serve.ex b/lib/egghead/cli/serve.ex index 1b2e09e..3e6bea4 100644 --- a/lib/egghead/cli/serve.ex +++ b/lib/egghead/cli/serve.ex @@ -8,19 +8,31 @@ defmodule Egghead.CLI.Serve do egghead serve [flags] DESCRIPTION - Start the web server and MCP HTTP endpoint. The MCP endpoint is - available at /mcp on the same port. Logs go to stdout. + Start the web server (with the MCP HTTP endpoint at /mcp) and the + IRC server. Both run in the same supervision tree on sensible + defaults — no config required. Logs go to stdout. For the MCP stdio transport (editor integration), use `egghead mcp`. FLAGS - --port Override the HTTP port (default: 4000) - --config PATH Override config file location - -h, --help Show this help + --port Override the HTTP port (default: 4000) + --irc-port Override the IRC port (default: 6667) + --no-web Skip starting the web / MCP-HTTP endpoint + --no-irc Skip starting the IRC server + --config PATH Override config file location + -h, --help Show this help + + ENVIRONMENT + PORT, EGGHEAD_HOST, EGGHEAD_BIND, EGGHEAD_WEB=false + EGGHEAD_IRC_PORT, EGGHEAD_IRC_BIND, EGGHEAD_IRC=false + EGGHEAD_IRC_PASSWORD (if `irc.password: "{env:...}"` is configured) EXAMPLES $ egghead serve $ egghead serve --port 8080 + $ egghead serve --irc-port 6697 + $ egghead serve --no-web # IRC-only + $ egghead serve --no-irc # web-only SEE ALSO egghead mcp, egghead config, egghead doctor @@ -33,7 +45,12 @@ defmodule Egghead.CLI.Serve do defp do_run(args) do {opts, _, _} = OptionParser.parse(args, - switches: [port: :integer], + switches: [ + port: :integer, + irc_port: :integer, + no_web: :boolean, + no_irc: :boolean + ], aliases: [] ) @@ -49,6 +66,14 @@ defmodule Egghead.CLI.Serve do ) end + # IRC port overrides have to land on the loaded Config struct because + # the IRC supervisor reads from there at boot. CLI flag wins over env + # var wins over config file (CLI runs after apply_config in start_app). + if irc_port = opts[:irc_port], do: put_irc_field(:port, irc_port) + + if opts[:no_web], do: Application.put_env(:egghead, :start_web, false) + if opts[:no_irc], do: Application.put_env(:egghead, :start_irc, false) + Egghead.CLI.start_app(:console) if Egghead.Node.connected?() do @@ -58,8 +83,16 @@ defmodule Egghead.CLI.Serve do end port = get_port() - IO.puts("Egghead running on http://localhost:#{port}") - IO.puts("MCP endpoint at http://localhost:#{port}/mcp") + + if Application.get_env(:egghead, :start_web, true) do + IO.puts("Egghead web on http://localhost:#{port}") + IO.puts("MCP endpoint at http://localhost:#{port}/mcp") + end + + case irc_listening_on() do + nil -> :ok + {host, irc_port} -> IO.puts("Egghead IRC on irc://#{host}:#{irc_port}") + end if node() != :nonode@nohost do IO.puts("Node: #{node()}") @@ -79,6 +112,36 @@ defmodule Egghead.CLI.Serve do Keyword.get(http, :port, 4000) end + defp irc_listening_on do + cond do + Application.get_env(:egghead, :start_irc, true) == false -> + nil + + Process.whereis(Egghead.IRC.Server) == nil -> + nil + + true -> + cfg = + case Application.get_env(:egghead, :config) do + %{irc: %{} = irc} -> irc + _ -> %{} + end + + {Map.get(cfg, :bind, "127.0.0.1"), Map.get(cfg, :port, 6667)} + end + end + + defp put_irc_field(key, value) do + case Application.get_env(:egghead, :config) do + %Egghead.Config{} = cfg -> + irc = Map.put(cfg.irc || %{}, key, value) + Application.put_env(:egghead, :config, %{cfg | irc: irc}) + + _ -> + :ok + end + end + # Show how a peer host should attach when this server is reachable # off-box. We only print the hint when `server.host` is configured — # that's the explicit signal that the operator wants cross-host diff --git a/lib/egghead/config.ex b/lib/egghead/config.ex index 06e40b0..292417a 100644 --- a/lib/egghead/config.ex +++ b/lib/egghead/config.ex @@ -28,6 +28,17 @@ defmodule Egghead.Config do port: 4000 host: localhost bind: 127.0.0.1 + + irc: + port: 6667 + bind: 127.0.0.1 + hostname: irc.local # optional; defaults to gethostname() + password: "{env:EGGHEAD_IRC_PASSWORD}" # optional shared password + + Both `web:` and `irc:` are fully optional — both servers start with the + defaults shown above. To disable a server, set `EGGHEAD_WEB=false` or + `EGGHEAD_IRC=false` in the environment, or pass `--no-web` / `--no-irc` + to `egghead serve`. """ defstruct records_dir: "~/.egghead", @@ -39,7 +50,8 @@ defmodule Egghead.Config do theme: "terminal-dark", web: %{port: 4000, host: "localhost", bind: "127.0.0.1"}, mcp_servers: [], - server: nil + server: nil, + irc: %{port: 6667, bind: "127.0.0.1", hostname: nil, password: nil} @type llm_entry :: %{ provider: String.t(), @@ -58,6 +70,13 @@ defmodule Egghead.Config do requires: [Egghead.Capability.Grant.t()] } + @type irc_config :: %{ + port: non_neg_integer(), + bind: String.t(), + hostname: String.t() | nil, + password: String.t() | nil + } + @type t :: %__MODULE__{ records_dir: String.t(), skills_dir: String.t(), @@ -67,7 +86,8 @@ defmodule Egghead.Config do default_room: String.t() | nil, theme: String.t(), web: %{port: non_neg_integer(), host: String.t(), bind: String.t()}, - mcp_servers: [mcp_server()] + mcp_servers: [mcp_server()], + irc: irc_config() } # --- Paths --- @@ -277,10 +297,34 @@ defmodule Egghead.Config do theme: data["theme"] || "terminal-dark", web: parse_web(data["web"]), mcp_servers: parse_mcp_servers(data["mcp_servers"]), - server: parse_server(data["server"]) + server: parse_server(data["server"]), + irc: parse_irc(data["irc"]) + } + end + + defp parse_irc(nil), do: default_irc() + + defp parse_irc(map) when is_map(map) do + %{ + port: int(map["port"], 6667), + bind: str(map["bind"], "127.0.0.1"), + hostname: str(map["hostname"], nil), + password: resolve_value(recover_env_ref(map["password"])) } end + defp parse_irc(_), do: default_irc() + + defp default_irc do + %{port: 6667, bind: "127.0.0.1", hostname: nil, password: nil} + end + + defp int(v, _default) when is_integer(v), do: v + defp int(_, default), do: default + + defp str(v, _default) when is_binary(v) and v != "", do: v + defp str(_, default), do: default + defp parse_mcp_servers(nil), do: [] defp parse_mcp_servers(list) when is_list(list) do @@ -416,6 +460,7 @@ defmodule Egghead.Config do emit_field("default_room", config.default_room), emit_field("theme", config.theme), emit_web(config.web), + emit_irc(config.irc), emit_mcp_servers(config.mcp_servers) ] @@ -532,6 +577,25 @@ defmodule Egghead.Config do if length(lines) > 1, do: Enum.join(lines, "\n"), else: nil end + defp emit_irc(%{port: 6667, bind: "127.0.0.1", hostname: nil, password: nil}), do: nil + defp emit_irc(nil), do: nil + + defp emit_irc(irc) do + lines = ["irc:"] + lines = if irc[:port] && irc.port != 6667, do: lines ++ [" port: #{irc.port}"], else: lines + + lines = + if irc[:bind] && irc.bind != "127.0.0.1", do: lines ++ [" bind: #{irc.bind}"], else: lines + + lines = + if irc[:hostname], do: lines ++ [" hostname: #{yaml_escape(irc.hostname)}"], else: lines + + lines = + if irc[:password], do: lines ++ [" password: #{yaml_escape(irc.password)}"], else: lines + + if length(lines) > 1, do: Enum.join(lines, "\n"), else: nil + end + # If YAML parsed {env:VAR} as a map %{"env:VAR" => nil}, recover the string form defp recover_env_ref(value) when is_map(value) do case Map.keys(value) do diff --git a/lib/egghead/irc/agents.ex b/lib/egghead/irc/agents.ex new file mode 100644 index 0000000..41bdc17 --- /dev/null +++ b/lib/egghead/irc/agents.ex @@ -0,0 +1,63 @@ +defmodule Egghead.IRC.Agents do + @moduledoc """ + Shared agent-roster lookups for the IRC layer. + + `Egghead.Agent.list_agents/0` requires the record store to be up. In + tests (and degraded headless modes) it isn't, and would crash the + caller — wrap so resolution / WHOIS gracefully report "no such nick" + instead of dropping the socket. + """ + + alias Egghead.IRC.NickMap + alias Egghead.Chat.Room + + @doc "Live agent roster, or `[]` if the agent layer is down." + def list do + try do + Egghead.Agent.list_agents() + catch + _, _ -> [] + end + end + + @doc "Find an agent record whose IRC nick matches `nick`, or nil." + def find_by_nick(nick) do + Enum.find(list(), fn a -> NickMap.id_to_nick(a.id) == nick end) + end + + @doc """ + Find an agent currently joined to `room_id` by IRC nick. + + Returns `{:ok, agent_id}` or `:not_found`. Used by KICK / MUTE / + UNMUTE — only the in-room roster, not the whole agent registry. + """ + def find_in_room(nick, room_id) do + if Room.exists?(room_id) do + case Room.get_state(room_id) do + %{agents: agents} -> + case Enum.find(agents, fn id -> NickMap.id_to_nick(id) == nick end) do + nil -> :not_found + id -> {:ok, id} + end + + _ -> + :not_found + end + else + :not_found + end + end + + @doc "Channel names for every running room that has `agent_id` joined." + def channels(agent_id) do + Room.list_ids() + |> Enum.filter(fn room_id -> + Room.exists?(room_id) and + case Room.get_state(room_id) do + %{agents: agents} -> agent_id in agents + _ -> false + end + end) + |> Enum.map(&NickMap.room_to_channel/1) + end +end diff --git a/lib/egghead/irc/channels.ex b/lib/egghead/irc/channels.ex new file mode 100644 index 0000000..33a466c --- /dev/null +++ b/lib/egghead/irc/channels.ex @@ -0,0 +1,77 @@ +defmodule Egghead.IRC.Channels do + @moduledoc """ + Per-connection channel-name aliasing. + + Strict IRC clients (ERC, irssi) only open a buffer when the JOIN + echo's channel name matches the channel name they typed — so if the + user types `JOIN #default` we have to echo `JOIN #default`, not the + canonical `JOIN #chat-2026-04-30-3906`. This module owns: + + - `resolve_alias/1` — incoming JOIN target → `{canonical, alias}` + - `display_channel/2` — `{room_id, alias_map}` → wire-side channel name + used for every outbound JOIN/PART/PRIVMSG/NAMES/action for that room + - `target_to_room_id/2` — inbound channel name → canonical room id, + walking three layers (per-conn alias, global `#default`, NickMap) + + Pure functions over a `%{room_id => alias_string}` map. + """ + + alias Egghead.IRC.NickMap + + @type alias_map :: %{optional(String.t()) => String.t()} + + @doc """ + Resolve a channel name from an inbound JOIN. Returns + `{canonical_channel, alias_or_nil}` — `#default` resolves to the + configured default room's canonical channel and an alias of + `#default`; everything else is its own canonical with no alias. + """ + @spec resolve_alias(String.t()) :: {String.t(), String.t() | nil} + def resolve_alias("#default") do + case Egghead.default_room() do + nil -> {"#default", nil} + room_id -> {NickMap.room_to_channel(room_id), "#default"} + end + end + + def resolve_alias(other), do: {other, nil} + + @doc "Record the alias name (if any) the user typed for `room_id`." + @spec put_alias(alias_map, String.t(), String.t() | nil) :: alias_map + def put_alias(aliases, _room_id, nil), do: aliases + def put_alias(aliases, room_id, alias_name), do: Map.put(aliases, room_id, alias_name) + + @doc """ + Channel name to use when emitting anything for `room_id` back over + the wire on this connection. Falls through to the canonical name + when no alias is set. + """ + @spec display_channel(alias_map, String.t()) :: String.t() + def display_channel(aliases, room_id) do + Map.get(aliases, room_id) || NickMap.room_to_channel(room_id) + end + + @doc """ + Reverse lookup for inbound traffic — channel name → canonical room + id. Three layers: + + 1. Per-connection alias (set on JOIN #default → that room id) + 2. Global `#default` alias (so KICK/INVITE work even without a JOIN) + 3. Canonical via NickMap + + Returns the room id, or nil if the channel name doesn't resolve. + """ + @spec target_to_room_id(alias_map, String.t()) :: String.t() | nil + def target_to_room_id(aliases, channel) do + cond do + match = Enum.find(aliases, fn {_room_id, alias_name} -> alias_name == channel end) -> + elem(match, 0) + + channel == "#default" -> + Egghead.default_room() || NickMap.channel_to_room(channel) + + true -> + NickMap.channel_to_room(channel) + end + end +end diff --git a/lib/egghead/irc/chat_history.ex b/lib/egghead/irc/chat_history.ex new file mode 100644 index 0000000..a003082 --- /dev/null +++ b/lib/egghead/irc/chat_history.ex @@ -0,0 +1,249 @@ +defmodule Egghead.IRC.ChatHistory do + @moduledoc """ + IRCv3 [chathistory](https://ircv3.net/specs/extensions/chathistory) + extension. Five subcommands: + + CHATHISTORY LATEST * + CHATHISTORY BEFORE timestamp= + CHATHISTORY AFTER timestamp= + CHATHISTORY AROUND timestamp= + CHATHISTORY BETWEEN timestamp= timestamp= + + Response: a `BATCH + chathistory ` envelope wrapping + one PRIVMSG per matching transcript message (each tagged with + `@time=` and `@batch=`), terminated by `BATCH -`. + Failures surface as `FAIL CHATHISTORY :`. + + Pulled out of `Connection` so the chathistory subprotocol lives on + its own. Wire emission goes through small callbacks the connection + passes in (`emit_line` and `time_tag`), keeping this module ignorant + of sockets and IRCv3 cap negotiation. + """ + + alias Egghead.IRC.{Protocol, Numerics, NickMap, Channels} + alias Egghead.Chat.Room + + @max 100 + + @doc "Cap on a single CHATHISTORY response (also the ISUPPORT advertisement)." + def max, do: @max + + @doc """ + Dispatch a parsed `CHATHISTORY` message. `ctx` is a small bundle: + + %{ + server: state.server, + nick: state.nick, + aliases: state.aliases, + emit: fn iodata -> ... end, # write a wire line + time_tag: fn DateTime.t() | nil -> map # IRCv3 server-time + } + """ + def handle(%Protocol.Message{} = msg, ctx) do + case Protocol.Message.args(msg) do + [subcommand | rest] -> + do_dispatch(String.upcase(subcommand), rest, ctx) + + [] -> + fail(ctx, "NEED_MORE_PARAMS", [], "CHATHISTORY needs a subcommand") + end + + :ok + end + + defp do_dispatch("LATEST", [target, _selector, limit_str | _], ctx) do + window(ctx, target, limit_str, fn _msg -> true end, :latest) + end + + defp do_dispatch("BEFORE", [target, ts_arg, limit_str | _], ctx) do + case parse_ts(ts_arg) do + {:ok, ts} -> + window( + ctx, + target, + limit_str, + fn m -> DateTime.compare(m.timestamp, ts) == :lt end, + :latest + ) + + :error -> + fail(ctx, "INVALID_PARAMS", [target], "BEFORE needs timestamp=") + end + end + + defp do_dispatch("AFTER", [target, ts_arg, limit_str | _], ctx) do + case parse_ts(ts_arg) do + {:ok, ts} -> + window( + ctx, + target, + limit_str, + fn m -> DateTime.compare(m.timestamp, ts) == :gt end, + :earliest + ) + + :error -> + fail(ctx, "INVALID_PARAMS", [target], "AFTER needs timestamp=") + end + end + + defp do_dispatch("AROUND", [target, ts_arg, limit_str | _], ctx) do + case parse_ts(ts_arg) do + {:ok, ts} -> + limit = clamp_limit(limit_str) + half = max(div(limit, 2), 1) + + emit(ctx, target, fn msgs -> + {before, after_} = + Enum.split_with(msgs, fn m -> DateTime.compare(m.timestamp, ts) != :gt end) + + (Enum.take(before, -half) ++ Enum.take(after_, half)) + |> Enum.take(limit) + end) + + :error -> + fail(ctx, "INVALID_PARAMS", [target], "AROUND needs timestamp=") + end + end + + defp do_dispatch("BETWEEN", [target, ts1_arg, ts2_arg, limit_str | _], ctx) do + with {:ok, ts1} <- parse_ts(ts1_arg), + {:ok, ts2} <- parse_ts(ts2_arg) do + {lo, hi} = if DateTime.compare(ts1, ts2) == :lt, do: {ts1, ts2}, else: {ts2, ts1} + + window( + ctx, + target, + limit_str, + fn m -> + DateTime.compare(m.timestamp, lo) != :lt and + DateTime.compare(m.timestamp, hi) != :gt + end, + :earliest + ) + else + _ -> + fail(ctx, "INVALID_PARAMS", [target], "BETWEEN needs two timestamp= args") + end + end + + defp do_dispatch(sub, args, ctx) do + target = List.first(args, "*") + fail(ctx, "UNKNOWN_COMMAND", [target], "CHATHISTORY #{sub} is not supported") + end + + # `which` is `:latest` (closest to "now") or `:earliest` (closest to + # the filter's pivot). + defp window(ctx, target, limit_str, filter, which) do + limit = clamp_limit(limit_str) + + emit(ctx, target, fn msgs -> + filtered = Enum.filter(msgs, filter) + + case which do + :latest -> Enum.take(filtered, -limit) + :earliest -> Enum.take(filtered, limit) + end + end) + end + + # Resolve target → room, fetch transcript, run selector, emit a + # BATCH-wrapped sequence of PRIVMSGs. + defp emit(ctx, target, selector) do + case Channels.target_to_room_id(ctx.aliases, target) do + nil -> + fail(ctx, "INVALID_TARGET", [target], "Unknown channel") + + room_id -> + if Room.exists?(room_id) do + transcript = + case Room.get_transcript(room_id) do + msgs when is_list(msgs) -> msgs + _ -> [] + end + + # /pass markers are a transcript convention, not chat content. + chat_only = + Enum.reject(transcript, fn m -> + m.sender.type == :agent and m.content == "/pass" + end) + + selected = selector.(chat_only) + send_batch(ctx, target, room_id, selected) + else + fail(ctx, "INVALID_TARGET", [target], "Channel does not exist") + end + end + end + + defp send_batch(ctx, target, room_id, msgs) do + batch_id = batch_id() + + ctx.emit.( + Protocol.encode( + prefix: ctx.server, + command: "BATCH", + params: ["+" <> batch_id, "chathistory", target] + ) + ) + + Enum.each(msgs, fn msg -> send_line(ctx, room_id, batch_id, msg) end) + + ctx.emit.(Protocol.encode(prefix: ctx.server, command: "BATCH", params: ["-" <> batch_id])) + end + + defp send_line(ctx, room_id, batch_id, msg) do + nick = + case msg.sender.type do + :user -> msg.sender.name + :agent -> NickMap.id_to_nick(msg.sender.id) + end + + channel = Channels.display_channel(ctx.aliases, room_id) + + tags = + ctx.time_tag.(msg.timestamp) + |> Map.put("batch", batch_id) + + msg.content + |> String.split(~r/\r?\n/) + |> Enum.reject(&(&1 == "")) + |> Enum.each(fn line -> + ctx.emit.( + Protocol.encode( + tags: tags, + prefix: nick, + command: "PRIVMSG", + params: [channel], + trailing: line + ) + ) + end) + end + + defp parse_ts("timestamp=" <> iso) do + case DateTime.from_iso8601(iso) do + {:ok, dt, _} -> {:ok, dt} + _ -> :error + end + end + + defp parse_ts(_), do: :error + + defp clamp_limit(str) when is_binary(str) do + case Integer.parse(str) do + {n, _} when n > 0 -> min(n, @max) + _ -> @max + end + end + + defp clamp_limit(_), do: @max + + defp batch_id, do: :crypto.strong_rand_bytes(6) |> Base.url_encode64(padding: false) + + defp fail(ctx, code, context, description) do + ctx.emit.( + Protocol.encode(Numerics.fail(ctx.server, "CHATHISTORY", code, context, description)) + ) + end +end diff --git a/lib/egghead/irc/connection.ex b/lib/egghead/irc/connection.ex new file mode 100644 index 0000000..205d71a --- /dev/null +++ b/lib/egghead/irc/connection.ex @@ -0,0 +1,1518 @@ +defmodule Egghead.IRC.Connection do + @moduledoc """ + Per-connection IRC handler. One process per TCP socket. + + Owns the connection state (registration status, nick, joined channels, + PubSub subscriptions) and translates between two halves of the world: + + - **Inbound**: bytes from the socket → `Egghead.IRC.Protocol.chunk/2` → + a `%Message{}` per line → `dispatch/2` → call into `Egghead.Chat.Room` + or reply directly with a numeric. + - **Outbound**: room PubSub events (`{:user_message, msg}`, + `{:agent_message, msg}`, `{:agent_joined, id}`, …) → IRC wire lines + written back to the socket. + + Implements `ThousandIsland.Handler`, which gives us `handle_connection/2`, + `handle_data/3`, `handle_close/2`, plus regular `GenServer.handle_info/2` + for PubSub messages. + """ + + use ThousandIsland.Handler + + require Logger + + alias Egghead.IRC.{ + Protocol, + Numerics, + NickMap, + Registry, + Server, + StreamBuffer, + Format, + Channels, + ChatHistory, + Forwarder, + Agents, + Whois, + Wire, + Verbs + } + + alias Egghead.Chat.Room + + # Server-side keepalive. Every `@ping_interval` ms we send a fresh + # PING to the client; the client must PONG back before the *next* + # tick fires or we close the connection. Without this, an idle ERC + # session would silently drop after Thousand Island's default 60s + # read_timeout — clean close, no log, ERC re-establishes the + # socket every minute. + @ping_interval 90_000 + + # IRCv3 capabilities the server advertises. + # + # - `server-time` lets clients render messages at the timestamp the + # server emits (not "now") — what makes scrollback feel real. + # - `batch` lets us wrap multi-message bursts (CHATHISTORY responses) + # in a `BATCH +id chathistory ...` ... `BATCH -id` envelope so + # clients distinguish history from live traffic. + # - `chathistory` advertises that the server understands the + # `CHATHISTORY` verb (LATEST / BEFORE / AFTER / AROUND / BETWEEN). + @supported_caps ["server-time", "batch", "chathistory"] + + # How many recent transcript messages to replay into a client's + # scrollback when they JOIN a channel — only sent if the client + # negotiated `server-time`, otherwise the messages would render at + # "now" and look like a confusing burst of duplicates. + @history_replay_count 50 + + # Cap on the number of messages a single CHATHISTORY query may return. + # Advertised in ISUPPORT as `CHATHISTORY=`. Clients clamp + # their requests to this; we clamp again on the server side as + # defense in depth. + @chathistory_max ChatHistory.max() + + # --- ThousandIsland.Handler callbacks --- + + @impl ThousandIsland.Handler + def handle_connection(socket, _state) do + cfg = Server.config() + + peer = + case ThousandIsland.Socket.peername(socket) do + {:ok, {ip, port}} -> "#{:inet.ntoa(ip)}:#{port}" + _ -> "?" + end + + Logger.info("IRC: connection opened from #{peer}") + + state = %{ + server: cfg.hostname, + version: cfg.version, + created_at: cfg.created_at, + password_required: not is_nil(cfg.password), + password: cfg.password, + password_ok: is_nil(cfg.password), + registered: false, + buffer: "", + nick: nil, + user: nil, + realname: nil, + channels: MapSet.new(), + cap_negotiating: false, + # Per-room PubSub forwarder Tasks. Maps room_id -> task pid. + # Task subscribes to `room:#{room_id}` and forwards each message + # back to us tagged `{:room_event, room_id, original}` — that's + # how the connection learns which room each event came from + # (Phoenix.PubSub doesn't expose the topic in handle_info). + routers: %{}, + # Per-(room, agent) streaming buffer. Tracks the cumulative text + # already emitted so we can mid-stream flush completed paragraphs + # as PRIVMSGs and emit the unflushed tail on the final + # :agent_message without doubling content. + streams: %{}, + # Per-connection channel-name aliases. When a user joins via + # `#default` we route to the canonical room id but echo JOIN / + # NAMES / PRIVMSG / actions back with the alias the client typed + # — strict clients (ERC) won't open a buffer when the JOIN echo + # references a different channel name than the request. Map shape: + # `%{room_id => "#alias-they-typed"}`. + aliases: %{}, + # Server-initiated keepalive state. `awaiting_pong_token` holds + # the unique token for the most recent PING we sent (nil when no + # PING is outstanding). `last_ping_sent_at` is the monotonic ms + # so we can report round-trip latency on the matching PONG. + # If the next tick fires while still awaiting, the connection + # is dead — close it. + awaiting_pong_token: nil, + last_ping_sent_at: nil, + # IRCv3 capabilities the client has negotiated. Determines + # whether outbound messages get `@time=` tags and whether JOIN + # replays scrollback from the room transcript. + caps: MapSet.new() + } + + {:continue, state} + end + + @impl ThousandIsland.Handler + def handle_data(data, socket, state) do + {messages, buffer} = Protocol.chunk(state.buffer, data) + state = %{state | buffer: buffer} + + Enum.reduce_while(messages, {:continue, state}, fn msg, {:continue, st} -> + case dispatch(msg, Map.put(st, :__socket__, socket)) do + {:continue, st2} -> {:cont, {:continue, drop_socket(st2)}} + {:close, st2} -> {:halt, {:close, drop_socket(st2)}} + end + end) + end + + @impl ThousandIsland.Handler + def handle_close(_socket, state) do + Logger.info("IRC: connection closed (nick=#{state.nick || "*"})") + cleanup(state) + :ok + end + + @impl ThousandIsland.Handler + def handle_error(reason, _socket, state) do + Logger.info("IRC: connection error (nick=#{state.nick || "*"}, reason=#{inspect(reason)})") + cleanup(state) + :ok + end + + # PubSub messages and other system messages route through GenServer + # handle_info; ThousandIsland passes them through unchanged. The + # callback receives `{socket, state}` and returns the same shape. + + # Per-room router task forwards every PubSub event from `room:#{room_id}` + # tagged with the room_id it came from — that's how we route correctly + # when this connection is in multiple rooms simultaneously. + def handle_info({:room_event, room_id, msg}, {socket, state}) do + state = handle_room_event(msg, room_id, socket, state) + {:noreply, {socket, state}} + end + + # Keepalive tick. If the client never PONG'd back the previous PING, + # they're dead — close the socket. Otherwise send a fresh PING and + # re-arm. + def handle_info(:keepalive_tick, {socket, %{awaiting_pong_token: tok} = state}) + when is_binary(tok) do + waited = monotonic_ms() - (state.last_ping_sent_at || 0) + + Logger.info( + "IRC: dropping #{state.nick || "*"} — no PONG for token=#{tok} within #{waited}ms" + ) + + cleanup(state) + {:stop, :normal, {socket, state}} + end + + def handle_info(:keepalive_tick, {socket, state}) do + token = fresh_ping_token() + + Logger.debug(fn -> "IRC: -> PING #{state.nick || "*"} token=#{token}" end) + + send_line( + socket, + Protocol.encode(prefix: state.server, command: "PING", trailing: token) + ) + + schedule_keepalive() + + {:noreply, {socket, %{state | awaiting_pong_token: token, last_ping_sent_at: monotonic_ms()}}} + end + + def handle_info(_other, {socket, state}) do + {:noreply, {socket, state}} + end + + defp schedule_keepalive do + Process.send_after(self(), :keepalive_tick, @ping_interval) + end + + # Short, unique tokens for server-initiated PINGs so we can match + # incoming PONGs to the correct outstanding request and report + # round-trip latency. + defp fresh_ping_token do + :crypto.strong_rand_bytes(4) |> Base.url_encode64(padding: false) + end + + defp monotonic_ms, do: System.monotonic_time(:millisecond) + + # Inbound PONG: log the round trip if we have a matching outstanding + # token, otherwise note the unsolicited PONG (e.g. client responding + # to its own clock or to a PING from a previous connection). + defp handle_pong_reply(msg, state) do + received = Protocol.Message.args(msg) |> List.first() + nick = state.nick || "*" + + Logger.debug(fn -> + cond do + is_nil(state.awaiting_pong_token) -> + "IRC: <- PONG #{nick} token=#{inspect(received)} (unsolicited)" + + received == state.awaiting_pong_token -> + rtt = monotonic_ms() - (state.last_ping_sent_at || 0) + "IRC: <- PONG #{nick} token=#{received} rtt=#{rtt}ms" + + true -> + "IRC: <- PONG #{nick} token=#{inspect(received)} (expected #{state.awaiting_pong_token})" + end + end) + end + + # --- Room event dispatch --- + # + # Every event the room (or coordinator) broadcasts on `room:#{room_id}` + # lands here once via the router. Each clause renders to the IRC wire: + # PRIVMSG for messages, NOTICE for system text, JOIN/PART for roster, + # CTCP ACTION for /pass, tool calls, and other atmospheric lines. + + # User chat — suppress own echo (see own_user_message?/2). + defp handle_room_event({:user_message, msg}, room_id, socket, state) do + unless own_user_message?(msg, state) do + send_privmsg(socket, state, msg.sender.name, room_id, msg.content) + end + + state + end + + # Final agent message. If we already streamed paragraphs of this turn, + # the stream buffer tells us how much we've emitted; only the suffix + # goes out as a fresh PRIVMSG. + defp handle_room_event({:agent_message, msg}, room_id, socket, state) do + nick = NickMap.id_to_nick(msg.sender.id) + {tail, streams} = StreamBuffer.take_tail(state.streams, room_id, msg.sender.id, msg.content) + state = %{state | streams: streams} + + if tail != "" do + send_privmsg(socket, state, nick, room_id, tail) + end + + state + end + + # Mid-stream token deltas — accumulate per-(room, agent), flush + # whole paragraphs (split on `\n\n`) as PRIVMSGs as they complete, + # keep the trailing partial buffered until the next chunk or the + # final :agent_message. + defp handle_room_event({:agent_streaming, _room_id, agent_id, delta}, room_id, socket, state) do + nick = NickMap.id_to_nick(agent_id) + {to_emit, streams} = StreamBuffer.absorb(state.streams, room_id, agent_id, delta) + state = %{state | streams: streams} + + if to_emit != "" do + send_privmsg(socket, state, nick, room_id, to_emit) + end + + state + end + + # /pass — atmospheric action line. Each renderer picks its own flavor + # from PassActions; the TUI does the same and may pick a different + # phrase. That's intentional, not a bug. + defp handle_room_event({:agent_passed, agent_id}, room_id, socket, state) do + nick = NickMap.id_to_nick(agent_id) + flavor = Egghead.Chat.PassActions.pick() + send_action(socket, state, nick, room_id, flavor) + state + end + + # Tool call — rendered as `*scout uses read_file path=foo.md*`, + # mirroring the TUI format (key=value pairs, values truncated to ~40 + # chars to keep the line readable). + defp handle_room_event( + {:agent_tool_call, _room_id, agent_id, name, input}, + room_id, + socket, + state + ) do + nick = NickMap.id_to_nick(agent_id) + summary = "uses #{name}#{Format.tool_input(input)}" + send_action(socket, state, nick, room_id, summary) + state + end + + # Tool denied by the capability layer — rendered as a CTCP ACTION + # so it reads alongside the tool-call line. The denial's `message` + # field is the human-readable reason (e.g. "no grant for net.get on + # api.example.com"); we don't surface the structured request/grants + # — those live in the log for operators. + defp handle_room_event( + {:agent_tool_denied, _room_id, agent_id, tool_name, _input, denial}, + room_id, + socket, + state + ) do + nick = NickMap.id_to_nick(agent_id) + reason = (denial && Map.get(denial, :message)) || "denied" + send_action(socket, state, nick, room_id, "was denied #{tool_name}: #{reason}") + state + end + + # Roster change — emit synthetic JOIN/PART so the IRC client's + # nicklist updates live without needing a fresh /NAMES query, then + # push a TOPIC update so the channel header reflects the new count. + defp handle_room_event({:agent_joined, agent_id}, room_id, socket, state) do + nick = NickMap.id_to_nick(agent_id) + channel = display_channel(state, room_id) + line = Protocol.encode(prefix: agent_prefix(nick, state), command: "JOIN", params: [channel]) + send_line(socket, line) + push_topic_update(socket, state, room_id) + state + end + + defp handle_room_event({:agent_left, agent_id}, room_id, socket, state) do + nick = NickMap.id_to_nick(agent_id) + channel = display_channel(state, room_id) + line = Protocol.encode(prefix: agent_prefix(nick, state), command: "PART", params: [channel]) + send_line(socket, line) + push_topic_update(socket, state, room_id) + state + end + + # Coordinator system notices — mute toggles, agent lifecycle, errors. + # IRC NOTICE is the right wire form: most clients render NOTICEs + # distinctly from PRIVMSG, which matches the TUI's dimmed gutter. + defp handle_room_event({:system_notice, text}, room_id, socket, state) do + send_notice(socket, state, room_id, text) + state + end + + # User halted the room (Ctrl-C in TUI, /halt in chat) — surface as a + # NOTICE so the IRC operator sees that agents are no longer responding + # without us hijacking the channel topic. + defp handle_room_event({:halted, _room_id}, room_id, socket, state) do + send_notice(socket, state, room_id, "Halted. Send another message to continue.") + state + end + + defp handle_room_event({:continued, opts}, room_id, socket, state) do + text = + case Keyword.get(opts, :replayed, 0) do + 0 -> "The room is quiet." + n -> "Continuing — #{n} queued activation(s) replayed." + end + + send_notice(socket, state, room_id, text) + state + end + + # Mute / unmute already comes through as :system_notice from the room + # ("Scout muted"/"Scout unmuted"); the explicit :muted_changed event + # is for sidebar UIs to flip an indicator. Nothing to render here. + defp handle_room_event({:muted_changed, _agent_id, _muted?}, _room_id, _socket, state), + do: state + + # Room shutdown — NOTICE the channel and tear down our subscription. + defp handle_room_event({:room_stopped, _room_id}, room_id, socket, state) do + send_notice(socket, state, room_id, "Room stopped") + drop_room(state, room_id) + end + + # Infrastructure-level events the IRC layer doesn't surface: + # roster_changed (we synthesize JOIN/PART instead), agents_activated, + # agent_mentions (coordinator-internal), reactivate, budget_exhausted, + # tool_output (verbose, low signal). + defp handle_room_event(_other, _room_id, _socket, state), do: state + + # --- Wire helpers for actions / notices --- + + # Channel-aware wrappers around `Wire` — these resolve the per-conn + # display alias and current server-time tag set, then delegate the + # actual line shaping. Kept as defps so the 100+ call sites in this + # module stay terse. + + defp send_action(socket, state, nick, room_id, text) do + Wire.send_action(socket, nick, display_channel(state, room_id), text, time_tag(state)) + state + end + + defp send_notice(socket, state, room_id, text) do + Wire.send_notice(socket, state.server, display_channel(state, room_id), text, time_tag(state)) + state + end + + defp agent_prefix(nick, state), do: Wire.agent_prefix(nick, state.server) + + defp time_tag(state, dt \\ nil), do: Wire.time_tag(state.caps, dt) + + # --- Command dispatch --- + + # We tuck the live socket into the state map for the duration of a + # dispatch call so the per-command handlers don't all need an extra + # arg. Stripped before returning. + defp drop_socket(state), do: Map.delete(state, :__socket__) + + defp dispatch(%Protocol.Message{command: cmd} = msg, state) do + case cmd do + "CAP" -> + handle_cap(msg, state) + + "PASS" -> + handle_pass(msg, state) + + "NICK" -> + handle_nick(msg, state) + + "USER" -> + handle_user(msg, state) + + "PING" -> + handle_ping(msg, state) + + "PONG" -> + handle_pong_reply(msg, state) + {:continue, %{state | awaiting_pong_token: nil, last_ping_sent_at: nil}} + + "QUIT" -> + handle_quit(msg, state) + + "JOIN" -> + require_registered(state, fn -> handle_join(msg, state) end) + + "PART" -> + require_registered(state, fn -> handle_part(msg, state) end) + + "PRIVMSG" -> + require_registered(state, fn -> handle_privmsg(msg, state) end) + + "NAMES" -> + require_registered(state, fn -> handle_names(msg, state) end) + + "MODE" -> + require_registered(state, fn -> handle_mode(msg, state) end) + + "LIST" -> + require_registered(state, fn -> handle_list(msg, state) end) + + # Egghead verbs — TUI slash-command palette over the IRC wire. + # ERC's `/handoff scout` sends `HANDOFF scout`; users get the + # exact muscle memory they have in the TUI. Implementation lives + # in `Egghead.IRC.Verbs`. + verb when verb in ["HANDOFF", "SAVE", "CONTINUE", "HALT", "MUTE", "UNMUTE", "CONTEXT"] -> + require_registered(state, fn -> + Verbs.handle(msg, verbs_ctx(state)) + {:continue, state} + end) + + "KICK" -> + require_registered(state, fn -> handle_kick(msg, state) end) + + "INVITE" -> + require_registered(state, fn -> handle_invite(msg, state) end) + + "WHOIS" -> + require_registered(state, fn -> handle_whois(msg, state) end) + + "MOTD" -> + require_registered(state, fn -> handle_motd(msg, state) end) + + "VERSION" -> + require_registered(state, fn -> handle_version(msg, state) end) + + "TIME" -> + require_registered(state, fn -> handle_time(msg, state) end) + + "CHATHISTORY" -> + require_registered(state, fn -> handle_chathistory(msg, state) end) + + _ -> + handle_unknown(msg, state) + end + end + + defp require_registered(%{registered: true}, fun), do: fun.() + + defp require_registered(state, _fun) do + reply(state, Numerics.not_registered(state.server)) + {:continue, state} + end + + # --- Registration --- + + defp handle_cap(msg, state) do + case Protocol.Message.args(msg) do + ["LS" | _] -> + # Advertise supported capabilities. Clients that don't care + # about CAP can ignore this; clients negotiating IRCv3 features + # will pick a subset and CAP REQ them. + reply( + state, + %Protocol.Message{ + prefix: state.server, + command: "CAP", + params: [nick_or_star(state), "LS"], + trailing: Enum.join(@supported_caps, " ") + } + ) + + {:continue, %{state | cap_negotiating: true}} + + ["REQ", req_caps] -> + requested = String.split(req_caps, " ", trim: true) + unsupported = Enum.reject(requested, &(&1 in @supported_caps)) + + if unsupported == [] do + new_caps = + Enum.reduce(requested, state.caps, fn cap, acc -> MapSet.put(acc, cap) end) + + reply( + state, + %Protocol.Message{ + prefix: state.server, + command: "CAP", + params: [nick_or_star(state), "ACK"], + trailing: req_caps + } + ) + + {:continue, %{state | caps: new_caps}} + else + # Per IRCv3, REQ is atomic: NAK the whole batch if any single + # cap is unsupported — partial acceptance breaks expectations. + reply( + state, + %Protocol.Message{ + prefix: state.server, + command: "CAP", + params: [nick_or_star(state), "NAK"], + trailing: req_caps + } + ) + + {:continue, state} + end + + ["END" | _] -> + {:continue, maybe_complete_registration(%{state | cap_negotiating: false})} + + _ -> + {:continue, state} + end + end + + defp handle_pass(msg, state) do + case Protocol.Message.args(msg) do + [pw | _] -> + cond do + state.registered -> + reply(state, Numerics.already_registered(state.server, nick_or_star(state))) + {:continue, state} + + not state.password_required -> + # PASS sent but no password configured — accept and ignore. + {:continue, %{state | password_ok: true}} + + pw == state.password -> + {:continue, %{state | password_ok: true}} + + true -> + reply(state, Numerics.passwd_mismatch(state.server)) + {:close, state} + end + + [] -> + reply(state, Numerics.need_more_params(state.server, nick_or_star(state), "PASS")) + {:continue, state} + end + end + + defp handle_nick(msg, state) do + case Protocol.Message.args(msg) do + [] -> nick_missing(state) + [requested | _] -> do_handle_nick(requested, state) + end + end + + defp nick_missing(state) do + reply(state, Numerics.no_nickname_given(state.server, nick_or_star(state))) + {:continue, state} + end + + defp do_handle_nick(requested, state) do + cond do + not NickMap.valid_nick?(requested) -> + reply(state, Numerics.erroneus_nickname(state.server, nick_or_star(state), requested)) + {:continue, state} + + state.nick == requested -> + {:continue, state} + + true -> + case claim_nick(state, requested) do + :ok -> + old = state.nick + state = %{state | nick: requested} + + if old && state.registered do + # NICK change after registration — echoed only to the + # changing connection. Cross-peer broadcast (so other + # humans in the same channel see the rename) requires a + # per-conn-room reverse index that doesn't exist yet. + line = + Protocol.encode( + prefix: prefix_for(old, state.user, state.server), + command: "NICK", + params: [requested] + ) + + send_line(state.__socket__, line) + end + + {:continue, maybe_complete_registration(state)} + + {:error, :nickname_in_use} -> + reply(state, Numerics.nickname_in_use(state.server, nick_or_star(state), requested)) + {:continue, state} + end + end + end + + defp handle_user(_msg, %{registered: true} = state) do + reply(state, Numerics.already_registered(state.server, state.nick)) + {:continue, state} + end + + defp handle_user(msg, state) do + case Protocol.Message.args(msg) do + [user, _mode, _unused, realname] -> + {:continue, maybe_complete_registration(%{state | user: user, realname: realname})} + + [user, _mode, _unused] -> + {:continue, maybe_complete_registration(%{state | user: user, realname: user})} + + _ -> + reply(state, Numerics.need_more_params(state.server, nick_or_star(state), "USER")) + {:continue, state} + end + end + + defp claim_nick(%{nick: nil}, requested), do: Registry.register(requested) + defp claim_nick(%{nick: old}, requested), do: Registry.rename(old, requested) + + defp maybe_complete_registration(%{registered: true} = state), do: state + + defp maybe_complete_registration(state) do + cond do + state.cap_negotiating -> state + not state.password_ok -> state + is_nil(state.nick) -> state + is_nil(state.user) -> state + true -> complete_registration(state) + end + end + + defp complete_registration(state) do + n = state.nick + s = state.server + + reply(state, Numerics.welcome(s, n)) + reply(state, Numerics.your_host(s, n, state.version)) + reply(state, Numerics.created(s, n, state.created_at)) + reply(state, Numerics.my_info(s, n, state.version)) + + reply( + state, + Numerics.isupport(s, n, [ + "NETWORK=Egghead", + "CHANTYPES=#", + "PREFIX=(v)+", + "NICKLEN=30", + "CASEMAPPING=ascii", + "CHATHISTORY=#{@chathistory_max}" + ]) + ) + + schedule_keepalive() + + Logger.info( + "IRC: registered nick=#{n} caps=#{inspect(MapSet.to_list(state.caps))} " <> + "(history-replay-on-join: #{if MapSet.member?(state.caps, "server-time"), do: "yes", else: "no — needs server-time cap"})" + ) + + %{state | registered: true} + end + + # --- Liveness --- + + defp handle_ping(msg, state) do + # Inbound `PING [:]token` from the client — echo `:server PONG :token` + # back. Some IRC clients (ERC included) compare the trailing token + # to what they sent; packing the server name in middle params as + # well confuses the match. Keep the response shape minimal. + token = Protocol.Message.args(msg) |> List.first() + nick = state.nick || "*" + + Logger.debug(fn -> "IRC: <- PING #{nick} token=#{inspect(token)}" end) + + response_token = token || state.server + Logger.debug(fn -> "IRC: -> PONG #{nick} token=#{response_token}" end) + + reply(state, %Protocol.Message{ + prefix: state.server, + command: "PONG", + trailing: response_token + }) + + {:continue, state} + end + + # --- Channel ops --- + + defp handle_join(msg, state) do + case Protocol.Message.args(msg) do + [channels | _] -> + state = + channels + |> String.split(",", trim: true) + |> Enum.reduce(state, fn ch, st -> do_join(ch, st) end) + + {:continue, state} + + [] -> + reply(state, Numerics.need_more_params(state.server, state.nick, "JOIN")) + {:continue, state} + end + end + + defp do_join(channel, state) do + # `#default` is a per-connection alias for the configured default + # room (or the auto-created dated fallback). Resolve to canonical + # room id internally, but remember the alias name so every wire + # echo for this connection (JOIN, NAMES, PRIVMSG, actions) uses + # the channel name the user actually typed. Strict clients (ERC) + # only open a buffer when the JOIN echo matches the request. + {canonical_channel, alias_name} = Channels.resolve_alias(channel) + + case NickMap.channel_to_room(canonical_channel) do + nil -> + reply( + state, + %Protocol.Message{ + prefix: state.server, + command: "403", + params: [state.nick, channel], + trailing: "No such channel" + } + ) + + state + + room_id -> + ensure_room(room_id) + + if MapSet.member?(state.channels, room_id) do + state + else + state = + state + |> subscribe_room(room_id) + |> Map.update!(:aliases, &Channels.put_alias(&1, room_id, alias_name)) + + display = display_channel(state, room_id) + + # Echo JOIN with the user's typed channel name — that's how + # the client knows the JOIN succeeded for *that* request. + send_line( + state.__socket__, + Protocol.encode( + prefix: prefix_for(state.nick, state.user, state.server), + command: "JOIN", + params: [display] + ) + ) + + # Topic before NAMES — common server ordering and keeps `366` + # (end-of-names) as the final marker of the JOIN burst. + send_topic(state, room_id) + send_names(display, room_id, state) + send_history(state.__socket__, state, room_id) + state + end + end + end + + # Replay the last `@history_replay_count` transcript messages into the + # client's scrollback. Gated on `server-time` — without it, the + # replayed lines would render at the current timestamp, which is + # actively misleading for old content (looks like a duplicate flood + # of "live" messages from minutes-or-days ago). Clients without + # server-time can still pull history on demand via `CHATHISTORY` if + # they support that cap; clients without either get nothing on JOIN. + defp send_history(socket, state, room_id) do + if MapSet.member?(state.caps, "server-time") and Room.exists?(room_id) do + transcript = + case Room.get_transcript(room_id) do + msgs when is_list(msgs) -> msgs + _ -> [] + end + + transcript + |> Enum.take(-@history_replay_count) + |> Enum.each(&send_history_message(socket, state, room_id, &1)) + end + end + + # Single transcript line as a backdated PRIVMSG. `/pass` markers + # (sender is :agent, content is "/pass") are skipped — they're a + # transcript convention, not text the user wants to see in scrollback. + defp send_history_message(_socket, _state, _room_id, %{ + sender: %{type: :agent}, + content: "/pass" + }), + do: :ok + + defp send_history_message(socket, state, room_id, msg) do + nick = + case msg.sender.type do + :user -> msg.sender.name + :agent -> NickMap.id_to_nick(msg.sender.id) + end + + Wire.send_privmsg( + socket, + nick, + display_channel(state, room_id), + msg.content, + time_tag(state, msg.timestamp) + ) + end + + # Synthesized channel topic — currently just an agent count. Lives in + # the channel header in most clients; cheap signal for "is this room + # active." Re-emitted whenever the roster changes (`:agent_joined` / + # `:agent_left`). + defp send_topic(state, room_id) do + channel = display_channel(state, room_id) + text = topic_text(room_id) + + reply(state, Numerics.topic_reply(state.server, state.nick, channel, text)) + + reply( + state, + Numerics.topic_who_time(state.server, state.nick, channel, "egghead", epoch_now()) + ) + end + + # Pushed-update form (no nick prefix in 332/333; sent as a top-level + # TOPIC line so connected clients refresh their header bar). + defp push_topic_update(socket, state, room_id) do + channel = display_channel(state, room_id) + text = topic_text(room_id) + + send_line( + socket, + Protocol.encode(prefix: state.server, command: "TOPIC", params: [channel], trailing: text) + ) + end + + defp topic_text(room_id) do + n = room_member_count(room_id) + plural = if n == 1, do: "agent", else: "agents" + "#{n} #{plural}" + end + + defp display_channel(state, room_id), do: Channels.display_channel(state.aliases, room_id) + + defp target_to_room_id(state, channel), + do: Channels.target_to_room_id(state.aliases, channel) + + defp subscribe_room(state, room_id) do + pid = Forwarder.start_link(self(), room_id) + + %{ + state + | channels: MapSet.put(state.channels, room_id), + routers: Map.put(state.routers, room_id, pid) + } + end + + defp drop_room(state, room_id) do + case Map.fetch(state.routers, room_id) do + {:ok, pid} -> Forwarder.stop(pid) + :error -> :ok + end + + %{ + state + | channels: MapSet.delete(state.channels, room_id), + routers: Map.delete(state.routers, room_id), + streams: StreamBuffer.drop_room(state.streams, room_id), + aliases: Map.delete(state.aliases, room_id) + } + end + + defp handle_part(msg, state) do + case Protocol.Message.args(msg) do + [channels | rest] -> + reason = List.first(rest, "") + + state = + channels + |> String.split(",", trim: true) + |> Enum.reduce(state, fn ch, st -> do_part(ch, reason, st) end) + + {:continue, state} + + [] -> + reply(state, Numerics.need_more_params(state.server, state.nick, "PART")) + {:continue, state} + end + end + + defp do_part(channel, _reason, state) do + case target_to_room_id(state, channel) do + nil -> + state + + room_id -> + if MapSet.member?(state.channels, room_id) do + # Echo PART with the channel name the user typed (which may + # be an alias) — same shape as the JOIN echo so the client's + # buffer-close logic recognizes it. + send_line( + state.__socket__, + Protocol.encode( + prefix: prefix_for(state.nick, state.user, state.server), + command: "PART", + params: [channel] + ) + ) + + drop_room(state, room_id) + else + state + end + end + end + + defp handle_privmsg(msg, state) do + case Protocol.Message.args(msg) do + [target, body] -> do_privmsg(target, body, state) + _ -> privmsg_missing(state) + end + end + + defp privmsg_missing(state) do + reply(state, Numerics.need_more_params(state.server, state.nick, "PRIVMSG")) + {:continue, state} + end + + # PRIVMSG to a nick (not a channel) is a DM. For agent nicks we send + # an ephemeral 1:1 prompt via `Egghead.prompt/3` and return the + # response as a PRIVMSG from the agent back to the asker. The prompt + # is async (LLM call, multi-second) so we spawn a Task and let the + # connection keep handling other commands. + # + # Human-to-human DM (target nick is another connected IRC client) + # isn't wired — would need to forward the PRIVMSG to the target + # connection's pid via Egghead.IRC.Registry.whereis/1 and a new + # handle_info clause on the receiving side. For now we 401 unknown + # nicks and NOTICE for known humans. + defp do_dm(nick, body, state) do + cond do + match = Agents.find_by_nick(nick) -> + spawn_dm_prompt(match.id, nick, body, state) + + Registry.whereis(nick) != nil -> + # Connected human — cross-connection DM routing not implemented. + send_line( + state.__socket__, + Protocol.encode( + prefix: state.server, + command: "NOTICE", + params: [state.nick], + trailing: "Human-to-human DMs are not wired" + ) + ) + + true -> + reply(state, Numerics.no_such_nick(state.server, state.nick, nick)) + end + end + + defp spawn_dm_prompt(agent_id, nick, body, state) do + socket = state.__socket__ + asker = state.nick + + Task.start(fn -> + case Egghead.prompt(agent_id, body) do + {:ok, %{text: text}} when is_binary(text) and text != "" -> + # PRIVMSG from the agent (prefix = agent's nick) to the asker + # — DMs in IRC are PRIVMSGs where the target is a nick rather + # than a channel. Split on newlines so multi-paragraph + # responses don't drop content. + text + |> String.split(~r/\r?\n/) + |> Enum.reject(&(&1 == "")) + |> Enum.each(fn line -> + send_line( + socket, + Protocol.encode( + prefix: nick, + command: "PRIVMSG", + params: [asker], + trailing: line + ) + ) + end) + + {:ok, _} -> + send_line( + socket, + Protocol.encode( + prefix: nick, + command: "NOTICE", + params: [asker], + trailing: "(no response)" + ) + ) + + {:error, reason} -> + send_line( + socket, + Protocol.encode( + prefix: nick, + command: "NOTICE", + params: [asker], + trailing: "DM failed: #{inspect(reason)}" + ) + ) + end + end) + end + + defp do_privmsg(target, body, state) do + case target_to_room_id(state, target) do + nil -> + do_dm(target, body, state) + {:continue, state} + + room_id -> + if MapSet.member?(state.channels, room_id) do + Room.send_message(room_id, body) + else + # Off-channel send — RFC says 404 ERR_CANNOTSENDTOCHAN; for our + # auto-join model we just silently drop, since the client likely + # has stale state. + :ok + end + + {:continue, state} + end + end + + defp handle_names(msg, state) do + case Protocol.Message.args(msg) do + [channels | _] -> + channels + |> String.split(",", trim: true) + |> Enum.each(fn ch -> + case target_to_room_id(state, ch) do + nil -> :ok + room_id -> send_names(ch, room_id, state) + end + end) + + {:continue, state} + + [] -> + {:continue, state} + end + end + + defp send_names(channel, room_id, state) do + nicks = roster_nicks(room_id, state) + reply(state, Numerics.names_reply(state.server, state.nick, channel, nicks)) + reply(state, Numerics.end_of_names(state.server, state.nick, channel)) + end + + defp roster_nicks(room_id, state) do + agent_nicks = + if Room.exists?(room_id) do + case Room.get_state(room_id) do + %{agents: agents} -> agents |> Enum.map(&("+" <> NickMap.id_to_nick(&1))) + _ -> [] + end + else + [] + end + + # Only this connection's own nick on the human side — finding + # other connected humans in the same channel needs a per-conn-room + # reverse index in `Egghead.IRC.Registry` (only nick→pid today). + [state.nick | agent_nicks] + end + + # --- MODE --- + # + # Channel mode queries (`MODE #room`) get a flat "no modes set" reply; + # we don't expose channel modes. User mode queries (`MODE nick`) + # likewise return empty. Mode *changes* (e.g. `MODE #room +o foo`) are + # ignored silently — agent mute/unmute uses the dedicated MUTE/UNMUTE + # verbs rather than channel-mode `+v`/`-v`. + + defp handle_mode(msg, state) do + case Protocol.Message.args(msg) do + [target | _] -> + cond do + target == state.nick -> + reply(state, Numerics.user_mode_is(state.server, state.nick)) + + target_to_room_id(state, target) != nil -> + reply(state, Numerics.channel_mode_is(state.server, state.nick, target)) + reply(state, Numerics.creation_time(state.server, state.nick, target, epoch_now())) + + true -> + :ok + end + + {:continue, state} + + [] -> + reply(state, Numerics.need_more_params(state.server, state.nick, "MODE")) + {:continue, state} + end + end + + # --- LIST --- + # + # `LIST` with no args lists every running room. `LIST #foo,#bar` filters + # to specific channels. We answer with a tiny envelope: 321 header, + # one 322 per channel (name, member count from agent roster, topic), + # 323 footer. Connected humans aren't included in the count — that + # would need a per-conn-room reverse index in `Egghead.IRC.Registry`. + + defp handle_list(msg, state) do + # ERC (and some other clients) send `LIST :` with an empty trailing + # param when the user types `/list` with no filter — args/1 then + # returns `[""]`, not `[]`. Flatten + reject empties so any of + # `LIST`, `LIST :`, `LIST ""`, `LIST #foo,#bar` collapse to either + # an empty filter list (= match everything) or a real channel set. + filters = + msg + |> Protocol.Message.args() + |> Enum.flat_map(&String.split(&1, ",", trim: true)) + + rooms = Room.list_ids() + default = Egghead.default_room() + + matching = + if filters == [], + do: rooms, + else: Enum.filter(rooms, fn r -> ("#" <> r) in filters end) + + Logger.debug(fn -> + "IRC LIST filters=#{inspect(filters)} rooms=#{inspect(rooms)} " <> + "matching=#{inspect(matching)} default=#{inspect(default)}" + end) + + reply(state, Numerics.list_start(state.server, state.nick)) + + Enum.each(matching, fn room_id -> + topic = + cond do + room_id == default -> "Default room — also reachable as #default" + true -> "" + end + + reply( + state, + Numerics.list_entry( + state.server, + state.nick, + NickMap.room_to_channel(room_id), + room_member_count(room_id), + topic + ) + ) + end) + + reply(state, Numerics.list_end(state.server, state.nick)) + {:continue, state} + end + + defp epoch_now, do: System.system_time(:second) + + # Member count for LIST. Counts agents currently joined to the room. + # Many IRC clients (ERC, weechat) hide 0-user channels in list-mode + # by default, treating them as inactive — reporting an honest count + # keeps active rooms visible. Connected humans aren't counted — + # would need a per-conn-room reverse index in `Egghead.IRC.Registry`. + defp room_member_count(room_id) do + if Room.exists?(room_id) do + case Room.get_state(room_id) do + # `agents` may arrive as a MapSet (live state) or a plain list + # (newly-started room with default state); `Enum.count/1` covers + # both without forcing one shape. + %{agents: agents} -> Enum.count(agents) + _ -> 0 + end + else + 0 + end + end + + # Slash-verb context bundle. Built per-dispatch from the live state — + # `Verbs.handle/2` reads it but doesn't keep a reference. + defp verbs_ctx(state) do + %{ + server: state.server, + nick: state.nick, + channels: state.channels, + aliases: state.aliases, + socket: state.__socket__, + emit: fn iodata -> Wire.write(state.__socket__, iodata) end + } + end + + # --- KICK / INVITE --- + # + # KICK and INVITE map to Room.leave/2 and Room.join/2 respectively. + # We deliberately don't model channel ops (no +o flag, no 482 + # ERR_CHANOPRIVSNEEDED gate) — Egghead rooms are flat and any + # participant can shape the roster, parallel to TUI semantics. + + defp handle_kick(msg, state) do + case Protocol.Message.args(msg) do + [channel, nick | _] -> + case target_to_room_id(state, channel) do + nil -> + reply(state, Numerics.no_such_nick(state.server, state.nick, channel)) + {:continue, state} + + room_id -> + unless MapSet.member?(state.channels, room_id) do + reply(state, Numerics.not_on_channel(state.server, state.nick, channel)) + end + + case Agents.find_in_room(nick, room_id) do + {:ok, agent_id} -> + Room.leave(room_id, agent_id) + + :not_found -> + reply(state, Numerics.no_such_nick(state.server, state.nick, nick)) + end + + {:continue, state} + end + + _ -> + reply(state, Numerics.need_more_params(state.server, state.nick, "KICK")) + {:continue, state} + end + end + + defp handle_invite(msg, state) do + # IRC convention is `INVITE ` — note the order + # differs from KICK. Some clients accept the reverse; tolerate both. + case Protocol.Message.args(msg) do + [a, b | _] -> + {nick, channel} = + cond do + String.starts_with?(a, "#") -> {b, a} + String.starts_with?(b, "#") -> {a, b} + true -> {a, b} + end + + do_invite(nick, channel, state) + + _ -> + reply(state, Numerics.need_more_params(state.server, state.nick, "INVITE")) + {:continue, state} + end + end + + defp do_invite(nick, channel, state) do + case target_to_room_id(state, channel) do + nil -> + reply(state, Numerics.no_such_nick(state.server, state.nick, channel)) + {:continue, state} + + room_id -> + case resolve_agent_anywhere(nick) do + {:ok, agent_id} -> + unless Room.exists?(room_id) do + # INVITE auto-creates like JOIN — otherwise typing + # `/invite scout #brand-new-room` would silently no-op + # or crash on the missing GenServer. + ensure_room(room_id) + end + + already? = + Room.exists?(room_id) and + case Room.get_state(room_id) do + %{agents: agents} -> agent_id in agents + _ -> false + end + + cond do + already? -> + reply(state, Numerics.user_on_channel(state.server, state.nick, nick, channel)) + + not Room.exists?(room_id) -> + reply(state, Numerics.no_such_nick(state.server, state.nick, channel)) + + true -> + Room.join(room_id, agent_id) + reply(state, Numerics.inviting(state.server, state.nick, nick, channel)) + end + + :not_found -> + # Only agent invites are wired. Inviting another connected + # human would forward an INVITE message to their connection + # process via Egghead.IRC.Registry.whereis/1. + reply(state, Numerics.no_such_nick(state.server, state.nick, nick)) + end + + {:continue, state} + end + end + + # Find an agent by IRC nick across the whole agent registry (not + # scoped to a room). Used by INVITE — KICK / MUTE / UNMUTE use + # `resolve_agent_in_room/2` instead since they only operate on the + # current roster. + defp resolve_agent_anywhere(nick) do + case Agents.find_by_nick(nick) do + nil -> :not_found + agent -> {:ok, agent.id} + end + end + + # --- WHOIS --- + # + # Subprotocol lives in `Egghead.IRC.Whois`; this clause builds the + # context bundle and delegates. + + defp handle_whois(msg, state) do + ctx = %{ + server: state.server, + nick: state.nick, + emit: fn iodata -> send_line(state.__socket__, iodata) end + } + + Whois.handle(msg, ctx) + {:continue, state} + end + + # --- MOTD / VERSION / TIME --- + + @motd [ + "Welcome to Egghead — record-store-first multi-agent system.", + "", + "Try /list to see active rooms.", + "Try /context for a snapshot of agent context windows.", + "Mention @everyone to address all agents at once,", + "or @ for a single one.", + "", + "Source: https://github.com/mwunsch/egghead" + ] + + defp handle_motd(_msg, state) do + reply(state, Numerics.motd_start(state.server, state.nick)) + Enum.each(@motd, fn line -> reply(state, Numerics.motd(state.server, state.nick, line)) end) + reply(state, Numerics.end_of_motd(state.server, state.nick)) + {:continue, state} + end + + defp handle_version(_msg, state) do + reply( + state, + Numerics.version_reply( + state.server, + state.nick, + state.version, + "Egghead IRC — record store + agents on tap" + ) + ) + + {:continue, state} + end + + defp handle_time(_msg, state) do + reply( + state, + Numerics.time_reply(state.server, state.nick, DateTime.utc_now() |> DateTime.to_iso8601()) + ) + + {:continue, state} + end + + # --- CHATHISTORY --- + # + # IRCv3 chat history extension. The subprotocol lives in + # `Egghead.IRC.ChatHistory`; this clause builds the small context + # bundle (server name, alias map, emit + time_tag callbacks) and + # delegates. + + defp handle_chathistory(msg, state) do + ctx = %{ + server: state.server, + nick: state.nick, + aliases: state.aliases, + emit: fn iodata -> send_line(state.__socket__, iodata) end, + time_tag: &time_tag(state, &1) + } + + ChatHistory.handle(msg, ctx) + {:continue, state} + end + + # --- QUIT --- + + defp handle_quit(_msg, state) do + cleanup(state) + {:close, state} + end + + # --- Unknown --- + + defp handle_unknown(%{command: cmd}, state) do + reply(state, Numerics.unknown_command(state.server, nick_or_star(state), cmd)) + {:continue, state} + end + + # --- Helpers --- + + defp ensure_room(room_id) do + cond do + Room.exists?(room_id) -> + :ok + + true -> + # `Egghead.create_room/1` is the canonical path — it auto-joins + # the registered agents to the new room. But it depends on the + # record store being up; in tests (and degraded headless mode) + # we may not have it. Fall back to a bare `Room.start_link/1` + # so an IRC client can still create and use a room. + try do + Egghead.create_room(id: room_id) + catch + _, _ -> Room.start_link(id: room_id) + else + {:ok, _} -> :ok + {:error, _} -> Room.start_link(id: room_id) + _ -> Room.start_link(id: room_id) + end + end + end + + defp cleanup(state) do + # Forwarders are linked to us, so they'd die with the socket + # regardless; stop them explicitly here for a clean PART scenario + # where the socket is still alive but the connection is winding down. + state + |> Map.get(:routers, %{}) + |> Map.values() + |> Enum.each(&Forwarder.stop/1) + + if state.nick, do: Registry.unregister(state.nick) + :ok + end + + defp send_privmsg(socket, state, from_nick, room_id, content) do + Wire.send_privmsg( + socket, + from_nick, + display_channel(state, room_id), + content, + time_tag(state) + ) + end + + defp reply(state, msg), do: Wire.send_message(state.__socket__, msg) + + defp send_line(socket, iodata), do: Wire.write(socket, iodata) + + defp prefix_for(nick, user, host), do: Wire.prefix(nick, user, host) + + defp nick_or_star(%{nick: nil}), do: "*" + defp nick_or_star(%{nick: n}), do: n + + defp own_user_message?(msg, state) do + msg.sender.type == :user and is_binary(state.nick) and msg.sender.name == state.nick + end +end diff --git a/lib/egghead/irc/format.ex b/lib/egghead/irc/format.ex new file mode 100644 index 0000000..5b729f4 --- /dev/null +++ b/lib/egghead/irc/format.ex @@ -0,0 +1,60 @@ +defmodule Egghead.IRC.Format do + @moduledoc """ + Pure rendering helpers shared across the IRC layer: + + - `tool_input/1` — formats a tool-call's arg map as a TUI-style + `key=value key=value` suffix, with values truncated to ~40 chars. + - `context_bar/1` — Claude Code-style `▓▓▓░░░` progress bar. + - `int/1` — three-digit comma grouping for token counts. + + No I/O, no state — moved out of `Connection` so the per-connection + module isn't carrying string formatting it doesn't need to own. + """ + + @doc """ + Format a tool-call's input map as a leading-space `key=value` + string, mirroring the TUI: `" path=foo.md mode=read"`. Empty input + → empty string. + """ + def tool_input(nil), do: "" + def tool_input(input) when input == %{}, do: "" + + def tool_input(input) when is_map(input) do + pairs = + input + |> Enum.map(fn {k, v} -> "#{k}=#{truncate_tool_value(v)}" end) + |> Enum.join(" ") + + if pairs == "", do: "", else: " " <> pairs + end + + def tool_input(_other), do: "" + + defp truncate_tool_value(v) when is_binary(v) do + cleaned = v |> String.replace(~r/\s+/, " ") |> String.trim() + if String.length(cleaned) > 40, do: String.slice(cleaned, 0, 37) <> "...", else: cleaned + end + + defp truncate_tool_value(v), do: v |> inspect() |> truncate_tool_value() + + @doc "16-cell context-window bar — `▓` filled, `░` empty." + def context_bar(pct) do + width = 16 + filled = round(pct / 100 * width) + String.duplicate("▓", filled) <> String.duplicate("░", width - filled) + end + + @doc "Comma-grouped integer for human-readable token counts." + def int(n) when is_integer(n) do + n + |> Integer.to_string() + |> String.reverse() + |> String.graphemes() + |> Enum.chunk_every(3) + |> Enum.map(&Enum.join/1) + |> Enum.join(",") + |> String.reverse() + end + + def int(_), do: "?" +end diff --git a/lib/egghead/irc/forwarder.ex b/lib/egghead/irc/forwarder.ex new file mode 100644 index 0000000..bf73249 --- /dev/null +++ b/lib/egghead/irc/forwarder.ex @@ -0,0 +1,46 @@ +defmodule Egghead.IRC.Forwarder do + @moduledoc """ + Per-room PubSub forwarder. One linked process per (connection, room). + + `Phoenix.PubSub` doesn't tell `handle_info` which topic delivered a + message — so each joined channel gets its own forwarder that + subscribes to the room's PubSub topic and re-sends every event back + to the parent connection tagged with the originating `room_id`. + Linked to the connection process: socket close kills the forwarder, + and unsubscribe is implicit when it exits. + """ + + alias Egghead.Chat.Room + + @pubsub Egghead.PubSub + + @doc """ + Spawn-link a forwarder for `room_id` that delivers + `{:room_event, room_id, msg}` tuples back to `parent`. Returns the + forwarder pid. + """ + @spec start_link(pid, String.t()) :: pid + def start_link(parent, room_id) do + spawn_link(fn -> init(parent, room_id) end) + end + + @doc "Stop a running forwarder. Idempotent." + @spec stop(pid) :: :ok + def stop(pid) when is_pid(pid) do + if Process.alive?(pid), do: Process.exit(pid, :normal) + :ok + end + + defp init(parent, room_id) do + Phoenix.PubSub.subscribe(@pubsub, Room.topic(room_id)) + loop(parent, room_id) + end + + defp loop(parent, room_id) do + receive do + msg -> + send(parent, {:room_event, room_id, msg}) + loop(parent, room_id) + end + end +end diff --git a/lib/egghead/irc/nick_map.ex b/lib/egghead/irc/nick_map.ex new file mode 100644 index 0000000..672193b --- /dev/null +++ b/lib/egghead/irc/nick_map.ex @@ -0,0 +1,136 @@ +defmodule Egghead.IRC.NickMap do + @moduledoc """ + Translation between Egghead identifiers (`agents/scout`, `users/mark`) + and IRC nicknames (`scout`, `mark`). + + IRC nicks are constrained to a narrower character set than our record + ids — no slashes, no leading digits, ASCII-ish — so the slash-namespaced + ids we use everywhere else have to be projected onto a flat namespace + before they hit the wire. Pure functions; collision policy lives here + rather than in the connection handler so it stays testable. + + ## Validity + + RFC 2812 §2.3.1 nickname grammar (slightly relaxed in modern practice): + + letter = A-Z | a-z + digit = 0-9 + special = '-' | '[' | ']' | '\\' | '`' | '_' | '^' | '{' | '|' | '}' + nickname = ( letter | special ) *( letter | digit | special ) + + We accept that grammar and additionally treat `.` as invalid (some + servers allow it; we don't, because it muddles host/nick parsing in + prefixes). + """ + + @max_nick_length 30 + + @doc """ + Project an Egghead id onto its IRC nick form. + + Strips a single leading namespace segment (`agents/scout` → `scout`, + `users/mark` → `mark`), replaces invalid characters with `_`, and + truncates to `@max_nick_length`. + + iex> Egghead.IRC.NickMap.id_to_nick("agents/scout") + "scout" + + iex> Egghead.IRC.NickMap.id_to_nick("users/mark") + "mark" + + iex> Egghead.IRC.NickMap.id_to_nick("agents/the.judge") + "the_judge" + """ + @spec id_to_nick(String.t()) :: String.t() + def id_to_nick(id) when is_binary(id) do + id + |> String.split("/") + |> List.last() + |> sanitize() + |> String.slice(0, @max_nick_length) + end + + defp sanitize(""), do: "_" + + defp sanitize(name) do + name + |> String.graphemes() + |> Enum.map(fn ch -> + if valid_char?(ch), do: ch, else: "_" + end) + |> Enum.join() + |> ensure_valid_first_char() + end + + defp ensure_valid_first_char(<> = name) do + if valid_first_char?(<>), do: name, else: "_" <> name + end + + defp ensure_valid_first_char(""), do: "_" + + defp valid_first_char?(<>) when ch in ?A..?Z or ch in ?a..?z, do: true + defp valid_first_char?(<>) when ch in [?[, ?], ?\\, ?`, ?_, ?^, ?{, ?|, ?}], do: true + defp valid_first_char?(_), do: false + + defp valid_char?(<>) when ch in ?A..?Z or ch in ?a..?z, do: true + defp valid_char?(<>) when ch in ?0..?9, do: true + defp valid_char?(<>) when ch in [?-, ?[, ?], ?\\, ?`, ?_, ?^, ?{, ?|, ?}], do: true + defp valid_char?(_), do: false + + @doc """ + Whether a string is a valid IRC nickname per the relaxed grammar above. + + iex> Egghead.IRC.NickMap.valid_nick?("scout") + true + + iex> Egghead.IRC.NickMap.valid_nick?("3llen") + false + + iex> Egghead.IRC.NickMap.valid_nick?("a.b") + false + + iex> Egghead.IRC.NickMap.valid_nick?("") + false + """ + @spec valid_nick?(String.t()) :: boolean() + def valid_nick?(name) when is_binary(name) do + case String.length(name) do + 0 -> false + n when n > @max_nick_length -> false + _ -> all_chars_valid?(name) + end + end + + def valid_nick?(_), do: false + + defp all_chars_valid?(<>) do + valid_first_char?(<>) and + Enum.all?(String.graphemes(rest), &valid_char?/1) + end + + @doc """ + Project a room id onto an IRC channel name (prefixes `#`). + + iex> Egghead.IRC.NickMap.room_to_channel("general") + "#general" + """ + @spec room_to_channel(String.t()) :: String.t() + def room_to_channel(room_id), do: "#" <> room_id + + @doc """ + Strip the `#` (or `&`/`+`/`!`) prefix from a channel name to recover + the room id. Returns `nil` if the input doesn't look like a channel. + + iex> Egghead.IRC.NickMap.channel_to_room("#general") + "general" + + iex> Egghead.IRC.NickMap.channel_to_room("general") + nil + """ + @spec channel_to_room(String.t()) :: String.t() | nil + def channel_to_room("#" <> rest), do: rest + def channel_to_room("&" <> rest), do: rest + def channel_to_room("+" <> rest), do: rest + def channel_to_room("!" <> rest), do: rest + def channel_to_room(_), do: nil +end diff --git a/lib/egghead/irc/numerics.ex b/lib/egghead/irc/numerics.ex new file mode 100644 index 0000000..c976d6f --- /dev/null +++ b/lib/egghead/irc/numerics.ex @@ -0,0 +1,394 @@ +defmodule Egghead.IRC.Numerics do + @moduledoc """ + IRC numeric reply codes (RFC 2812 §5). + + Helpers that build pre-shaped `%Egghead.IRC.Protocol.Message{}` structs + for the most common server replies. Connection handlers use these + instead of hand-assembling each reply, so the wire format stays + consistent and the call sites read close to the RFC. + + Every server-originated message takes a `nick` argument as the first + parameter — that's RFC-required (the recipient's nick is always echoed + back). For pre-registration replies, callers pass `"*"` per convention. + """ + + alias Egghead.IRC.Protocol.Message + + @doc "001 RPL_WELCOME — sent immediately after registration completes." + def welcome(server, nick, network \\ "Egghead") do + %Message{ + prefix: server, + command: "001", + params: [nick], + trailing: "Welcome to the #{network} IRC Network #{nick}" + } + end + + @doc "002 RPL_YOURHOST — server identification string." + def your_host(server, nick, version) do + %Message{ + prefix: server, + command: "002", + params: [nick], + trailing: "Your host is #{server}, running version #{version}" + } + end + + @doc "003 RPL_CREATED — server boot time string." + def created(server, nick, since) when is_binary(since) do + %Message{ + prefix: server, + command: "003", + params: [nick], + trailing: "This server was created #{since}" + } + end + + @doc """ + 004 RPL_MYINFO — server name, version, user modes, channel modes. + We expose no user modes and a tiny channel mode set today (`m` mute); + the parameter is informational and clients mostly ignore it. + """ + def my_info(server, nick, version) do + %Message{ + prefix: server, + command: "004", + params: [nick, server, version, "", "m"] + } + end + + @doc """ + 005 RPL_ISUPPORT — capabilities the server advertises. Clients use + this to size buffers and decide which features to enable. + + Each parameter token is a `KEY` or `KEY=value` pair; the trailing + string `are supported by this server` is RFC convention. + """ + def isupport(server, nick, tokens) when is_list(tokens) do + %Message{ + prefix: server, + command: "005", + params: [nick] ++ tokens, + trailing: "are supported by this server" + } + end + + @doc """ + 353 RPL_NAMREPLY — one chunk of the NAMES list for a channel. + + `members` is a list of nicks. Agent nicks should arrive with their + prefix already attached (e.g. `+scout` for voice). The `=` between + nick and channel marks the channel as public (vs. `*` secret, `@` private). + """ + def names_reply(server, nick, channel, members) when is_list(members) do + %Message{ + prefix: server, + command: "353", + params: [nick, "=", channel], + trailing: Enum.join(members, " ") + } + end + + @doc "366 RPL_ENDOFNAMES — terminates a NAMES burst." + def end_of_names(server, nick, channel) do + %Message{ + prefix: server, + command: "366", + params: [nick, channel], + trailing: "End of /NAMES list" + } + end + + @doc "311 RPL_WHOISUSER — nick / user / host / realname for a WHOIS reply." + def whois_user(server, asker, nick, user, host, realname) do + %Message{ + prefix: server, + command: "311", + params: [asker, nick, user, host, "*"], + trailing: realname + } + end + + @doc "312 RPL_WHOISSERVER — server name + info for a WHOIS reply." + def whois_server(server, asker, nick, server_name, info) do + %Message{ + prefix: server, + command: "312", + params: [asker, nick, server_name], + trailing: info + } + end + + @doc "317 RPL_WHOISIDLE — idle seconds + signon timestamp." + def whois_idle(server, asker, nick, idle_seconds, signon_epoch) do + %Message{ + prefix: server, + command: "317", + params: [asker, nick, Integer.to_string(idle_seconds), Integer.to_string(signon_epoch)], + trailing: "seconds idle, signon time" + } + end + + @doc "318 RPL_ENDOFWHOIS — terminates a WHOIS burst." + def end_of_whois(server, asker, nick) do + %Message{ + prefix: server, + command: "318", + params: [asker, nick], + trailing: "End of WHOIS list" + } + end + + @doc "319 RPL_WHOISCHANNELS — list of channels the nick is in." + def whois_channels(server, asker, nick, channels) when is_list(channels) do + %Message{ + prefix: server, + command: "319", + params: [asker, nick], + trailing: Enum.join(channels, " ") + } + end + + @doc """ + 320 RPL_WHOISSPECIAL — nominally "free-form info," but in practice + many clients (ERC, hexchat) hard-code it as "is identified to + services" regardless of trailing text. Avoid for arbitrary metadata; + use realname (311) or bot marker (335) instead. Kept for callers + that have a use for the literal-services semantic. + """ + def whois_special(server, asker, nick, line) do + %Message{prefix: server, command: "320", params: [asker, nick], trailing: line} + end + + @doc "335 RPL_WHOISBOT — modern marker rendered distinctly by current clients." + def whois_bot(server, asker, nick, network \\ "Egghead") do + %Message{ + prefix: server, + command: "335", + params: [asker, nick], + trailing: "is a bot on #{network}" + } + end + + @doc "341 RPL_INVITING — confirms an INVITE was sent." + def inviting(server, asker, target_nick, channel) do + %Message{prefix: server, command: "341", params: [asker, target_nick, channel]} + end + + @doc "351 RPL_VERSION — server version string." + def version_reply(server, asker, version, comments) do + %Message{ + prefix: server, + command: "351", + params: [asker, version, server], + trailing: comments + } + end + + @doc "372 RPL_MOTD — one line of the MOTD (server convention prefixes `- `)." + def motd(server, nick, line) do + %Message{prefix: server, command: "372", params: [nick], trailing: "- " <> line} + end + + @doc "375 RPL_MOTDSTART — header for the MOTD burst." + def motd_start(server, nick) do + %Message{ + prefix: server, + command: "375", + params: [nick], + trailing: "- #{server} Message of the day -" + } + end + + @doc "376 RPL_ENDOFMOTD — terminator for MOTD burst." + def end_of_motd(server, nick) do + %Message{prefix: server, command: "376", params: [nick], trailing: "End of /MOTD command"} + end + + @doc "391 RPL_TIME — server local time." + def time_reply(server, nick, time_string) do + %Message{prefix: server, command: "391", params: [nick, server], trailing: time_string} + end + + @doc "401 ERR_NOSUCHNICK — nick (or channel) doesn't exist." + def no_such_nick(server, asker, target) do + %Message{ + prefix: server, + command: "401", + params: [asker, target], + trailing: "No such nick/channel" + } + end + + @doc "442 ERR_NOTONCHANNEL — issuer isn't on the target channel." + def not_on_channel(server, asker, channel) do + %Message{ + prefix: server, + command: "442", + params: [asker, channel], + trailing: "You're not on that channel" + } + end + + @doc "443 ERR_USERONCHANNEL — INVITE target is already in the channel." + def user_on_channel(server, asker, target_nick, channel) do + %Message{ + prefix: server, + command: "443", + params: [asker, target_nick, channel], + trailing: "is already on channel" + } + end + + @doc """ + FAIL — IRCv3 standard replies extension. Used by CHATHISTORY (and + other modern verbs) to surface structured errors that older + numerics can't express. Format: + + FAIL [...] : + """ + def fail(server, command, code, context \\ [], description) do + %Message{ + prefix: server, + command: "FAIL", + params: [command, code | context], + trailing: description + } + end + + @doc "221 RPL_UMODEIS — user's current mode flags (we expose none)." + def user_mode_is(server, nick) do + %Message{prefix: server, command: "221", params: [nick, "+"]} + end + + @doc "331 RPL_NOTOPIC — channel exists but has no topic set." + def no_topic(server, nick, channel) do + %Message{prefix: server, command: "331", params: [nick, channel], trailing: "No topic is set"} + end + + @doc "332 RPL_TOPIC — current topic of the channel." + def topic_reply(server, nick, channel, topic) do + %Message{prefix: server, command: "332", params: [nick, channel], trailing: topic} + end + + @doc "333 RPL_TOPICWHOTIME — non-RFC-2812 but widely supported: who set the topic and when." + def topic_who_time(server, nick, channel, setter, epoch) do + %Message{ + prefix: server, + command: "333", + params: [nick, channel, setter, Integer.to_string(epoch)] + } + end + + @doc """ + 321 RPL_LISTSTART — header line for a LIST reply burst. Most modern + clients ignore this and only consume RPL_LIST entries, but RFC 2812 + expects it. + """ + def list_start(server, nick) do + %Message{ + prefix: server, + command: "321", + params: [nick, "Channel", "Users"], + trailing: "Name" + } + end + + @doc "322 RPL_LIST — one channel in a LIST reply (#channel, user count, topic)." + def list_entry(server, nick, channel, user_count, topic) do + %Message{ + prefix: server, + command: "322", + params: [nick, channel, Integer.to_string(user_count)], + trailing: topic || "" + } + end + + @doc "323 RPL_LISTEND — terminates a LIST burst." + def list_end(server, nick) do + %Message{prefix: server, command: "323", params: [nick], trailing: "End of /LIST"} + end + + @doc """ + 324 RPL_CHANNELMODEIS — current modes on a channel. We don't expose + any channel modes today (mute is per-agent and handled at the + Coordinator level), so the mode string is always `+`. + """ + def channel_mode_is(server, nick, channel) do + %Message{prefix: server, command: "324", params: [nick, channel, "+"]} + end + + @doc "329 RPL_CREATIONTIME — channel creation epoch (Unix seconds)." + def creation_time(server, nick, channel, epoch) do + %Message{ + prefix: server, + command: "329", + params: [nick, channel, Integer.to_string(epoch)] + } + end + + @doc "421 ERR_UNKNOWNCOMMAND — server doesn't recognize the verb." + def unknown_command(server, nick, command) do + %Message{ + prefix: server, + command: "421", + params: [nick, command], + trailing: "Unknown command" + } + end + + @doc "431 ERR_NONICKNAMEGIVEN — NICK with no argument." + def no_nickname_given(server, nick) do + %Message{prefix: server, command: "431", params: [nick], trailing: "No nickname given"} + end + + @doc "432 ERR_ERRONEUSNICKNAME — NICK with invalid characters." + def erroneus_nickname(server, nick, attempted) do + %Message{ + prefix: server, + command: "432", + params: [nick, attempted], + trailing: "Erroneous nickname" + } + end + + @doc "433 ERR_NICKNAMEINUSE — NICK collides with a connected client or agent." + def nickname_in_use(server, nick, attempted) do + %Message{ + prefix: server, + command: "433", + params: [nick, attempted], + trailing: "Nickname is already in use" + } + end + + @doc "451 ERR_NOTREGISTERED — caller hasn't completed NICK + USER yet." + def not_registered(server) do + %Message{prefix: server, command: "451", params: ["*"], trailing: "You have not registered"} + end + + @doc "461 ERR_NEEDMOREPARAMS — command was missing required arguments." + def need_more_params(server, nick, command) do + %Message{ + prefix: server, + command: "461", + params: [nick, command], + trailing: "Not enough parameters" + } + end + + @doc "462 ERR_ALREADYREGISTERED — second USER attempt after registration." + def already_registered(server, nick) do + %Message{ + prefix: server, + command: "462", + params: [nick], + trailing: "Unauthorized command (already registered)" + } + end + + @doc "464 ERR_PASSWDMISMATCH — PASS missing or wrong." + def passwd_mismatch(server) do + %Message{prefix: server, command: "464", params: ["*"], trailing: "Password incorrect"} + end +end diff --git a/lib/egghead/irc/protocol.ex b/lib/egghead/irc/protocol.ex new file mode 100644 index 0000000..1c798d7 --- /dev/null +++ b/lib/egghead/irc/protocol.ex @@ -0,0 +1,268 @@ +defmodule Egghead.IRC.Protocol do + @moduledoc """ + IRC wire protocol parser and encoder (RFC 1459 / 2812 + IRCv3 message tags). + + Pure functions — no I/O, no state. The connection layer feeds raw bytes + to `chunk/2` (which buffers across reads, splits on CRLF, and parses each + complete line into a `%Message{}`); it formats outbound `%Message{}`s with + `encode/1`. + + ## Wire grammar + + [@tag[=value][;tag[=value]]... ] [:prefix ] command [ params... ] [ :trailing ] CRLF + + - **tags** — IRCv3 message tags. `key=value` pairs separated by `;`. Stored + on the message as a `%{key => value}` map (we don't escape values yet — + the only producer is us, and we only emit ASCII tag values for now). + - **prefix** — `nick[!user][@host]` for client→server is rare; server→client + almost always present. Stored verbatim as a string. + - **command** — alphabetic verb (`PRIVMSG`, `JOIN`) or 3-digit numeric (`001`). + Upcased on parse so handlers can match on canonical form. + - **params** — up to 14 space-delimited tokens, then a trailing param + introduced by ` :` that may contain spaces. We don't enforce the 14-arg + cap on parse (clients sometimes violate it harmlessly). + + Lines are CRLF-terminated, max 512 bytes including CRLF (RFC 2812 §2.3). + IRCv3 tags add up to 8191 bytes of tag prefix beyond that. We don't enforce + the cap on input — modern clients can exceed it — and `encode/1` doesn't + truncate either; the caller decides. + """ + + defmodule Message do + @moduledoc """ + Parsed IRC message. + + - `tags` — IRCv3 tag map (`%{}` if none). + - `prefix` — source string (`"nick!user@host"` or server name) or `nil`. + - `command` — uppercased verb (`"PRIVMSG"`) or 3-digit numeric (`"001"`). + - `params` — middle params only (space-delimited tokens, no spaces inside). + - `trailing` — the trailing param (introduced on the wire by ` :`), or + `nil` if absent. May contain spaces. The encoder always emits `:` + before this even when it's a single token, which is the convention + every common IRC server follows for descriptive last params. + + On parse, if a line ends with a `:`-introduced trailing param, it lands + here — not in `params`. So `params ++ [trailing]` reconstructs the + full argument list when callers don't care about the wire distinction. + """ + + @type t :: %__MODULE__{ + tags: %{String.t() => String.t() | true}, + prefix: String.t() | nil, + command: String.t(), + params: [String.t()], + trailing: String.t() | nil + } + + defstruct tags: %{}, prefix: nil, command: "", params: [], trailing: nil + + @doc """ + All arguments (middle + trailing) as a flat list. Convenience for + handlers that don't care which slot the trailing param landed in. + """ + def args(%__MODULE__{params: p, trailing: nil}), do: p + def args(%__MODULE__{params: p, trailing: t}), do: p ++ [t] + end + + @crlf "\r\n" + + @doc """ + Feed a chunk of bytes from the socket. Returns + `{messages, leftover_buffer}` — `messages` are complete lines parsed + into `%Message{}` (in order received), `leftover_buffer` is any partial + trailing line that should be prepended to the next chunk. + + iex> {msgs, rest} = Egghead.IRC.Protocol.chunk("", "NICK foo\\r\\nUSER bar 0 * :Bar\\r\\n") + iex> Enum.map(msgs, & &1.command) + ["NICK", "USER"] + iex> rest + "" + + iex> {msgs, rest} = Egghead.IRC.Protocol.chunk("", "NICK foo\\r\\nPART") + iex> length(msgs) + 1 + iex> rest + "PART" + """ + @spec chunk(binary(), binary()) :: {[Message.t()], binary()} + def chunk(buffer, bytes) when is_binary(buffer) and is_binary(bytes) do + combined = buffer <> bytes + do_split(combined, []) + end + + defp do_split(buffer, acc) do + case :binary.split(buffer, @crlf) do + [^buffer] -> + # No CRLF found — buffer holds an incomplete line. + {Enum.reverse(acc), buffer} + + ["", rest] -> + # Empty line (just CRLF) — skip. + do_split(rest, acc) + + [line, rest] -> + case parse(line) do + {:ok, msg} -> do_split(rest, [msg | acc]) + # Drop unparseable lines silently for now. Worth a Logger.debug + # once we have a feel for what real clients send that we miss. + {:error, _} -> do_split(rest, acc) + end + end + end + + @doc """ + Parse one CRLF-stripped line into a `%Message{}`. + + iex> {:ok, m} = Egghead.IRC.Protocol.parse("PRIVMSG #room :hello world") + iex> {m.command, m.params, m.trailing} + {"PRIVMSG", ["#room"], "hello world"} + + iex> {:ok, m} = Egghead.IRC.Protocol.parse(":nick!u@h JOIN #room") + iex> {m.prefix, m.command, m.params} + {"nick!u@h", "JOIN", ["#room"]} + + iex> {:ok, m} = Egghead.IRC.Protocol.parse("@time=2026 PING :server") + iex> {Map.get(m.tags, "time"), m.command, m.trailing} + {"2026", "PING", "server"} + """ + @spec parse(binary()) :: {:ok, Message.t()} | {:error, :empty | :no_command} + def parse(line) when is_binary(line) do + line = String.trim_trailing(line, "\r") + + case line do + "" -> {:error, :empty} + _ -> parse_tags(line, %Message{}) + end + end + + defp parse_tags("@" <> rest, msg) do + case :binary.split(rest, " ") do + [tag_str, after_tags] -> + parse_prefix(after_tags, %{msg | tags: parse_tag_string(tag_str)}) + + [_only_tags] -> + {:error, :no_command} + end + end + + defp parse_tags(line, msg), do: parse_prefix(line, msg) + + defp parse_tag_string(str) do + str + |> String.split(";", trim: true) + |> Enum.reduce(%{}, fn pair, acc -> + case :binary.split(pair, "=") do + [k, v] -> Map.put(acc, k, v) + [k] -> Map.put(acc, k, true) + end + end) + end + + defp parse_prefix(":" <> rest, msg) do + case :binary.split(rest, " ") do + [prefix, after_prefix] -> parse_command(after_prefix, %{msg | prefix: prefix}) + [_only_prefix] -> {:error, :no_command} + end + end + + defp parse_prefix(line, msg), do: parse_command(line, msg) + + defp parse_command(line, msg) do + case :binary.split(String.trim_leading(line, " "), " ") do + [cmd] when cmd != "" -> + {:ok, %{msg | command: String.upcase(cmd)}} + + [cmd, params] when cmd != "" -> + {middle, trailing} = parse_params(params) + {:ok, %{msg | command: String.upcase(cmd), params: middle, trailing: trailing}} + + _ -> + {:error, :no_command} + end + end + + # Walks the parameter portion. Tokens are space-separated until we + # hit a token that begins with `:`, which marks the trailing param — + # everything from there to end-of-line is one parameter (spaces and all). + defp parse_params(str) do + do_parse_params(str, []) + end + + defp do_parse_params("", acc), do: {Enum.reverse(acc), nil} + + defp do_parse_params(":" <> trailing, acc), do: {Enum.reverse(acc), trailing} + + defp do_parse_params(str, acc) do + case :binary.split(str, " ") do + [token] -> {Enum.reverse([token | acc]), nil} + [token, rest] -> do_parse_params(String.trim_leading(rest, " "), [token | acc]) + end + end + + @doc """ + Encode a `%Message{}` (or a keyword list of fields) into a CRLF-terminated + wire line. + + Pass `trailing:` for the `:`-prefixed last parameter (always emitted with + the leading `:`). `params:` is for middle params (no spaces, no leading `:`). + + iex> Egghead.IRC.Protocol.encode(prefix: "irc.local", command: "001", params: ["nick"], trailing: "Welcome") + ":irc.local 001 nick :Welcome\\r\\n" + + iex> Egghead.IRC.Protocol.encode(command: "PING", trailing: "server.local") + "PING :server.local\\r\\n" + + iex> Egghead.IRC.Protocol.encode(command: "JOIN", params: ["#room"]) + "JOIN #room\\r\\n" + """ + @spec encode(Message.t() | keyword()) :: binary() + def encode(%Message{} = m) do + encode( + prefix: m.prefix, + command: m.command, + params: m.params, + trailing: m.trailing, + tags: m.tags + ) + end + + def encode(fields) when is_list(fields) do + tags = Keyword.get(fields, :tags, %{}) + prefix = Keyword.get(fields, :prefix) + command = Keyword.fetch!(fields, :command) + params = Keyword.get(fields, :params, []) + trailing = Keyword.get(fields, :trailing) + + [ + encode_tags(tags), + encode_prefix(prefix), + command, + encode_params(params), + encode_trailing(trailing), + @crlf + ] + |> IO.iodata_to_binary() + end + + defp encode_tags(map) when map_size(map) == 0, do: "" + + defp encode_tags(map) do + pairs = + map + |> Enum.map_join(";", fn + {k, true} -> k + {k, v} -> "#{k}=#{v}" + end) + + ["@", pairs, " "] + end + + defp encode_prefix(nil), do: "" + defp encode_prefix(p), do: [":", p, " "] + + defp encode_params([]), do: "" + defp encode_params(params), do: Enum.map(params, &[" ", &1]) + + defp encode_trailing(nil), do: "" + defp encode_trailing(t), do: [" :", t] +end diff --git a/lib/egghead/irc/registry.ex b/lib/egghead/irc/registry.ex new file mode 100644 index 0000000..41a3137 --- /dev/null +++ b/lib/egghead/irc/registry.ex @@ -0,0 +1,75 @@ +defmodule Egghead.IRC.Registry do + @moduledoc """ + Registry of live IRC connections, keyed by nickname. + + One entry per registered (NICK + USER complete) connection. Used by + the connection handler to detect nick collisions on `NICK` and by + future code paths (DM lookup, `egghead irc status`) to find a peer + by nick. + + Started as a child of `Egghead.IRC.Supervisor`. Pre-registration + connections are not in the registry — they have no nick yet. + """ + + @doc "Returns the child spec for the connection registry." + def child_spec(_opts) do + Registry.child_spec(keys: :unique, name: __MODULE__) + end + + @doc """ + Register the calling process under `nick`. Returns `:ok` if the nick + is free, `{:error, :nickname_in_use}` if another live connection holds it. + """ + @spec register(String.t()) :: :ok | {:error, :nickname_in_use} + def register(nick) when is_binary(nick) do + case Registry.register(__MODULE__, key(nick), nil) do + {:ok, _pid} -> :ok + {:error, {:already_registered, _pid}} -> {:error, :nickname_in_use} + end + end + + @doc "Release the calling process's claim on `nick` (if it holds it)." + @spec unregister(String.t()) :: :ok + def unregister(nick) when is_binary(nick) do + Registry.unregister(__MODULE__, key(nick)) + :ok + end + + @doc """ + Re-register the calling process from `old_nick` to `new_nick` atomically + enough for our needs (Registry doesn't expose a true atomic rename, but + collisions on `new_nick` are rejected before we drop the old one). + """ + @spec rename(String.t(), String.t()) :: :ok | {:error, :nickname_in_use} + def rename(old_nick, new_nick) do + case register(new_nick) do + :ok -> + unregister(old_nick) + :ok + + {:error, _} = err -> + err + end + end + + @doc "Look up the pid holding `nick`, or `nil`." + @spec whereis(String.t()) :: pid() | nil + def whereis(nick) when is_binary(nick) do + case Registry.lookup(__MODULE__, key(nick)) do + [{pid, _}] -> pid + [] -> nil + end + end + + @doc "All currently-registered nicks, sorted alphabetically (case-insensitive)." + @spec all_nicks() :: [String.t()] + def all_nicks do + Registry.select(__MODULE__, [{{:"$1", :_, :_}, [], [:"$1"]}]) + |> Enum.sort_by(&String.downcase/1) + end + + # IRC nicks are case-insensitive on the wire — `Mark` and `mark` collide. + # Store the canonical (lowercase) form as the key; the connection + # remembers the display-cased version separately. + defp key(nick), do: String.downcase(nick) +end diff --git a/lib/egghead/irc/server.ex b/lib/egghead/irc/server.ex new file mode 100644 index 0000000..7683d24 --- /dev/null +++ b/lib/egghead/irc/server.ex @@ -0,0 +1,149 @@ +defmodule Egghead.IRC.Server do + @moduledoc """ + IRC server lifecycle: Thousand Island TCP listener wrapped in our + own supervisor along with the connection registry. + + Started by `Egghead.Application` whenever `:start_irc` is true (which + is the default — same shape as `:start_web`). Disable via `--no-irc` + on `egghead serve`, `EGGHEAD_IRC=false` in the environment, or + `config :egghead, :start_irc, false` at compile time. + + The server stores a small read-only config in `:persistent_term` at + boot so per-connection handlers can fetch hostname / version / password + without going through a GenServer. That config is updated only at + start (and not changed while running), so persistent_term's + copy-on-update tradeoff doesn't matter here. + + ## Config + + irc: + port: 6667 + bind: 127.0.0.1 + hostname: irc.local # optional; defaults to gethostname() + password: "{env:EGGHEAD_IRC_PASSWORD}" # optional shared password + + All fields are optional. With no `irc:` block, the server starts on + 127.0.0.1:6667 with no auth and a hostname derived from the local + system. Override the port with `--irc-port` or `EGGHEAD_IRC_PORT`, + the bind address with `EGGHEAD_IRC_BIND=0.0.0.0`. + """ + + use Supervisor + + require Logger + + alias Egghead.IRC.{Connection, Registry} + + @default_port 6667 + + def child_spec(opts) do + %{ + id: __MODULE__, + start: {__MODULE__, :start_link, [opts]}, + type: :supervisor, + restart: :permanent + } + end + + def start_link(opts) do + Supervisor.start_link(__MODULE__, opts, name: __MODULE__) + end + + @impl true + def init(opts) do + irc_cfg = Keyword.fetch!(opts, :config) + store_config(irc_cfg) + + bind = parse_bind(Map.get(irc_cfg, :bind, "127.0.0.1")) + port = Map.get(irc_cfg, :port, @default_port) + + children = [ + Registry, + { + ThousandIsland, + # ThousandIsland defaults to a 60s read_timeout — if no inbound + # bytes arrive in that window, it kills the connection without + # going through our `handle_close` callback. That fires before + # our 90s server-side keepalive even ticks, which is exactly + # the disconnect-every-minute behavior live IRC clients hit. + # Disable it; `Egghead.IRC.Connection`'s PING/PONG keepalive + # already detects dead clients on a tighter, observable cycle. + port: port, + transport_options: [ip: bind], + handler_module: Connection, + handler_options: [], + read_timeout: :infinity + } + ] + + Logger.info("IRC server listening on #{format_addr(bind)}:#{port}") + + Supervisor.init(children, strategy: :one_for_one) + end + + @doc """ + Read-only config snapshot for connection handlers. Stored at boot; + callers should not mutate the returned map. Includes: + + - `:hostname` — what to use as IRC server name in prefixes / numerics + - `:version` — egghead version string for 002/004 replies + - `:created_at` — string for 003 RPL_CREATED + - `:password` — `nil` if no auth, else the shared password + """ + @spec config() :: map() + def config do + :persistent_term.get({__MODULE__, :config}, default_runtime_config()) + end + + defp store_config(irc_cfg) do + hostname = + case Map.get(irc_cfg, :hostname) do + h when is_binary(h) and h != "" -> + h + + _ -> + case :inet.gethostname() do + {:ok, name} -> List.to_string(name) + _ -> "egghead.local" + end + end + + version = + case Application.spec(:egghead, :vsn) do + nil -> "dev" + v -> "egghead-#{List.to_string(v)}" + end + + cfg = %{ + hostname: hostname, + version: version, + created_at: DateTime.utc_now() |> DateTime.to_iso8601(), + password: Map.get(irc_cfg, :password) + } + + :persistent_term.put({__MODULE__, :config}, cfg) + end + + defp default_runtime_config do + %{ + hostname: "egghead.local", + version: "dev", + created_at: DateTime.utc_now() |> DateTime.to_iso8601(), + password: nil + } + end + + defp parse_bind("0.0.0.0"), do: {0, 0, 0, 0} + + defp parse_bind(addr) when is_binary(addr) do + case :inet.parse_address(String.to_charlist(addr)) do + {:ok, ip} -> ip + _ -> {127, 0, 0, 1} + end + end + + defp parse_bind(_), do: {127, 0, 0, 1} + + defp format_addr({a, b, c, d}), do: "#{a}.#{b}.#{c}.#{d}" + defp format_addr(other), do: inspect(other) +end diff --git a/lib/egghead/irc/stream_buffer.ex b/lib/egghead/irc/stream_buffer.ex new file mode 100644 index 0000000..43af14c --- /dev/null +++ b/lib/egghead/irc/stream_buffer.ex @@ -0,0 +1,95 @@ +defmodule Egghead.IRC.StreamBuffer do + @moduledoc """ + Per-(room, agent) paragraph buffer for streaming agent output. + + Mid-stream token deltas (`{:agent_streaming, …}`) accumulate here until + one or more complete paragraphs (split on `\\n\\n`) are ready to flush + as PRIVMSG lines. The trailing partial sits in the buffer until either + the next chunk completes another paragraph or the final + `{:agent_message, msg}` arrives — at which point `take_tail/3` returns + whatever the streaming path didn't already emit. + + Pure functions over a `%{{room_id, agent_id} => %{buffer, emitted}}` + map, no process state of its own. + """ + + @type key :: {String.t(), String.t()} + @type entry :: %{buffer: String.t(), emitted: non_neg_integer()} + @type t :: %{optional(key) => entry} + + @doc """ + Append `delta` to the per-(room, agent) buffer. Returns + `{to_emit, new_streams}` where `to_emit` is any complete paragraphs + ready to flush as PRIVMSG (may be `""`). + """ + @spec absorb(t, String.t(), String.t(), String.t()) :: {String.t(), t} + def absorb(streams, room_id, agent_id, delta) do + key = {room_id, agent_id} + buffer = (streams[key] || %{buffer: "", emitted: 0}).buffer + combined = buffer <> delta + + case last_paragraph_break(combined) do + nil -> + new = Map.put(streams, key, %{buffer: combined, emitted: emitted(streams, key)}) + {"", new} + + cut -> + to_emit = binary_part(combined, 0, cut) + rest = binary_part(combined, cut + 2, byte_size(combined) - cut - 2) + + new_emitted = emitted(streams, key) + cut + 2 + new = Map.put(streams, key, %{buffer: rest, emitted: new_emitted}) + {to_emit, new} + end + end + + @doc """ + On final `:agent_message`, return any text the streaming path didn't + emit and clear the per-(room, agent) entry. Idempotent — if there + was no streaming for this turn, returns the entire `full_content`. + """ + @spec take_tail(t, String.t(), String.t(), String.t()) :: {String.t(), t} + def take_tail(streams, room_id, agent_id, full_content) do + key = {room_id, agent_id} + + case Map.get(streams, key) do + nil -> + {full_content, streams} + + %{emitted: e} -> + tail = + if e < byte_size(full_content) do + binary_part(full_content, e, byte_size(full_content) - e) + else + "" + end + + {tail, Map.delete(streams, key)} + end + end + + @doc "Drop every entry for a given room (used on PART / room_stopped)." + @spec drop_room(t, String.t()) :: t + def drop_room(streams, room_id) do + streams + |> Enum.reject(fn {{rid, _agent_id}, _} -> rid == room_id end) + |> Map.new() + end + + defp emitted(streams, key) do + case Map.get(streams, key) do + nil -> 0 + %{emitted: e} -> e + end + end + + # Last `\n\n` boundary in the buffer — that's how far we can safely + # flush as completed paragraphs. Returns the byte offset of the first + # `\n` of the boundary, or nil if none found. + defp last_paragraph_break(text) do + case :binary.matches(text, "\n\n") do + [] -> nil + matches -> matches |> List.last() |> elem(0) + end + end +end diff --git a/lib/egghead/irc/verbs.ex b/lib/egghead/irc/verbs.ex new file mode 100644 index 0000000..3d214ec --- /dev/null +++ b/lib/egghead/irc/verbs.ex @@ -0,0 +1,216 @@ +defmodule Egghead.IRC.Verbs do + @moduledoc """ + Egghead-specific slash-command verbs over IRC: SAVE, CONTINUE, HALT, + MUTE, UNMUTE, HANDOFF, CONTEXT. These mirror the TUI's slash palette + so ERC's `/handoff scout` Just Works. + + IRC commands don't carry a "current channel" on the wire — when the + user types `/save` in their `#foo` buffer, ERC sends a bare `SAVE`. + Each verb resolves the target room via `resolve_room_arg/2`: explicit + `#channel` first arg wins; otherwise default to the user's only + joined channel; otherwise 461 NEEDMOREPARAMS. + + All wire emission flows through the `emit` callback the connection + passes in (matching `ChatHistory` / `Whois`), so this module stays + ignorant of sockets. + """ + + alias Egghead.IRC.{Protocol, Numerics, NickMap, Channels, Format, Agents, Wire} + alias Egghead.Chat.Room + + @type ctx :: %{ + required(:server) => String.t(), + required(:nick) => String.t(), + required(:channels) => MapSet.t(), + required(:aliases) => map(), + required(:socket) => term(), + required(:emit) => (iodata() -> any()) + } + + @verbs ~w(SAVE CONTINUE HALT MUTE UNMUTE HANDOFF CONTEXT) + + @doc "List of verbs handled by this module. Used by `Connection.dispatch`." + def verbs, do: @verbs + + @doc "Dispatch a parsed slash-verb message." + def handle(%Protocol.Message{command: cmd} = msg, ctx) when cmd in @verbs do + do_handle(cmd, msg, ctx) + end + + defp do_handle("SAVE", msg, ctx) do + with_room(msg, ctx, fn _args, room_id -> + case Room.save_transcript(room_id) do + {:ok, record_id} -> notice(ctx, "Saved transcript as #{record_id}") + {:error, reason} -> notice(ctx, "Save failed: #{inspect(reason)}") + end + end) + end + + defp do_handle("CONTINUE", msg, ctx) do + with_room(msg, ctx, fn _args, room_id -> Room.continue(room_id) end) + end + + defp do_handle("HALT", msg, ctx) do + with_room(msg, ctx, fn _args, room_id -> Room.halt(room_id) end) + end + + defp do_handle("MUTE", msg, ctx) do + with_room_and_agent(msg, ctx, fn _agent_arg, room_id, agent_id -> + Room.mute(room_id, agent_id) + end) + end + + defp do_handle("UNMUTE", msg, ctx) do + with_room_and_agent(msg, ctx, fn _agent_arg, room_id, agent_id -> + Room.unmute(room_id, agent_id) + end) + end + + # HANDOFF runs an LLM summarization call (multi-second). Spawn it so + # the connection stays responsive; report completion via NOTICE. + defp do_handle("HANDOFF", msg, ctx) do + with_room_and_agent(msg, ctx, fn agent_arg, _room_id, agent_id -> + socket = ctx.socket + server = ctx.server + nick = ctx.nick + + Task.start(fn -> + case Egghead.handoff(agent_id, []) do + {:ok, _summary} -> + Wire.send_notice( + socket, + server, + nick, + "#{agent_arg}: handoff complete (context cleared, summary saved)" + ) + + {:error, reason} -> + Wire.send_notice( + socket, + server, + nick, + "#{agent_arg}: handoff failed (#{inspect(reason)})" + ) + end + end) + + notice(ctx, "Handing off #{agent_arg}…") + end) + end + + # /context — Claude Code-style snapshot. One line per agent, percentage + # bar + raw counts, sent as a NOTICE block. + defp do_handle("CONTEXT", msg, ctx) do + with_room(msg, ctx, fn _args, room_id -> + Enum.each(context_report(room_id), ¬ice(ctx, &1)) + end) + end + + # --- Verb argument resolution --- + + # Pulls a channel arg or falls back to the user's only joined channel. + # Calls `fun.(remaining_args, room_id)` on success; emits 461 if no + # channel can be inferred. Returns :ok regardless (slash verbs don't + # mutate connection state). + defp with_room(msg, ctx, fun) do + args = Protocol.Message.args(msg) + cmd = msg.command + + case resolve_room_arg(args, ctx) do + {:ok, room_id, rest} -> + fun.(rest, room_id) + + {:error, :no_channel} -> + reply(ctx, Numerics.need_more_params(ctx.server, ctx.nick, cmd)) + + {:error, :ambiguous} -> + notice(ctx, "You're in multiple channels — specify one (#room) as the first argument.") + + {:error, :unknown_channel} -> + reply(ctx, Numerics.need_more_params(ctx.server, ctx.nick, cmd)) + end + + :ok + end + + defp with_room_and_agent(msg, ctx, fun) do + cmd = msg.command + + with_room(msg, ctx, fn rest, room_id -> + case rest do + [agent_nick | _] -> + case Agents.find_in_room(agent_nick, room_id) do + {:ok, agent_id} -> + fun.(agent_nick, room_id, agent_id) + + :not_found -> + reply(ctx, %Protocol.Message{ + prefix: ctx.server, + command: "401", + params: [ctx.nick, agent_nick], + trailing: "No such nick in this room" + }) + end + + [] -> + reply(ctx, Numerics.need_more_params(ctx.server, ctx.nick, cmd)) + end + end) + end + + defp resolve_room_arg(args, ctx) do + case args do + ["#" <> _ = channel | rest] -> + case Channels.target_to_room_id(ctx.aliases, channel) do + nil -> {:error, :unknown_channel} + room_id -> {:ok, room_id, rest} + end + + _ -> + case MapSet.to_list(ctx.channels) do + [] -> {:error, :no_channel} + [room_id] -> {:ok, room_id, args} + _ -> {:error, :ambiguous} + end + end + end + + defp context_report(room_id) do + room_agent_ids = + if Room.exists?(room_id) do + case Room.get_state(room_id) do + %{agents: agents} -> Enum.into(agents, []) + _ -> [] + end + else + [] + end + + case room_agent_ids do + [] -> + ["No agents in this room."] + + ids -> + roster = Enum.filter(Agents.list(), fn a -> a.id in ids end) + + max_nick = + roster |> Enum.map(&String.length(NickMap.id_to_nick(&1.id))) |> Enum.max(fn -> 0 end) + + ["Context windows:"] ++ + Enum.map(roster, fn agent -> + nick = NickMap.id_to_nick(agent.id) + tokens = agent.current_context_tokens || 0 + window = agent.context_window || 0 + pct = if window > 0, do: round(tokens / window * 100), else: 0 + bar = Format.context_bar(pct) + + " #{String.pad_trailing(nick, max_nick)} #{bar} #{String.pad_leading("#{pct}%", 4)} " <> + "(#{Format.int(tokens)} / #{Format.int(window)})" + end) + end + end + + defp reply(ctx, %Protocol.Message{} = m), do: ctx.emit.(Protocol.encode(m)) + + defp notice(ctx, text), do: Wire.send_notice(ctx.socket, ctx.server, ctx.nick, text) +end diff --git a/lib/egghead/irc/whois.ex b/lib/egghead/irc/whois.ex new file mode 100644 index 0000000..b7b50da --- /dev/null +++ b/lib/egghead/irc/whois.ex @@ -0,0 +1,110 @@ +defmodule Egghead.IRC.Whois do + @moduledoc """ + WHOIS handling. + + WHOIS for an agent packs model + context % into 311's realname, + tags + capabilities into 312's server-info, walks the agent's room + memberships for 319, and emits 335 RPL_WHOISBOT to mark the nick as + a bot in modern clients. WHOIS for a connected human returns 311 + + 312 only — joined-channels for humans needs a per-conn-room reverse + index in `Egghead.IRC.Registry`. + + Wire emission goes through an `emit` callback the connection passes + in, mirroring the `ChatHistory` shape. + """ + + alias Egghead.IRC.{Protocol, Numerics, Registry, Agents} + + @doc """ + Dispatch a parsed `WHOIS` message. `ctx` is: + + %{server: state.server, nick: state.nick, emit: fn iodata -> ... end} + """ + def handle(%Protocol.Message{} = msg, ctx) do + case Protocol.Message.args(msg) do + [target | _] -> + cond do + agent = Agents.find_by_nick(target) -> agent_reply(target, agent, ctx) + Registry.whereis(target) != nil -> human_reply(target, ctx) + true -> reply(ctx, Numerics.no_such_nick(ctx.server, ctx.nick, target)) + end + + reply(ctx, Numerics.end_of_whois(ctx.server, ctx.nick, target)) + + [] -> + reply(ctx, Numerics.need_more_params(ctx.server, ctx.nick, "WHOIS")) + end + + :ok + end + + defp agent_reply(nick, agent, ctx) do + # Pack metadata into the realname (311) and server-info (312) + # fields, which clients render verbatim. Avoid 320 RPL_WHOISSPECIAL + # — ERC and several other clients hardcode it as "is identified to + # services" regardless of trailing text. 335 RPL_WHOISBOT marks + # agents distinctly in modern clients. + # + # NOTE: deliberately not surfacing `agent.disposition`. That field + # is `record.body || ""` (see `lib/egghead/record/agent.ex`) — i.e. + # the whole system prompt, multi-paragraph. Client renderers wrap + # it across many lines. Tags and capabilities are short labels + # that fit on one line each. + ctx_tokens = agent.current_context_tokens || 0 + window = agent.context_window || 0 + pct = if window > 0, do: round(ctx_tokens / window * 100), else: 0 + + realname = + [agent.name, agent.model || "no model", "context #{pct}%"] + |> Enum.join(" · ") + + info = + ["Egghead agent · #{agent.id}"] + |> maybe_append(format_tags(agent.tags), fn t -> "tags: #{t}" end) + |> maybe_append(format_caps(agent.capabilities), fn c -> "caps: #{c}" end) + |> Enum.join(" · ") + + reply(ctx, Numerics.whois_user(ctx.server, ctx.nick, nick, "agent", ctx.server, realname)) + reply(ctx, Numerics.whois_server(ctx.server, ctx.nick, nick, ctx.server, info)) + + case Agents.channels(agent.id) do + [] -> :ok + chans -> reply(ctx, Numerics.whois_channels(ctx.server, ctx.nick, nick, chans)) + end + + reply(ctx, Numerics.whois_bot(ctx.server, ctx.nick, nick)) + end + + defp human_reply(nick, ctx) do + reply(ctx, Numerics.whois_user(ctx.server, ctx.nick, nick, "user", ctx.server, nick)) + + reply( + ctx, + Numerics.whois_server(ctx.server, ctx.nick, nick, ctx.server, "Egghead human user") + ) + end + + defp reply(ctx, %Protocol.Message{} = m), do: ctx.emit.(Protocol.encode(m)) + + defp maybe_append(list, nil, _fmt), do: list + defp maybe_append(list, "", _fmt), do: list + defp maybe_append(list, [], _fmt), do: list + defp maybe_append(list, value, fmt), do: list ++ [fmt.(value)] + + defp format_caps(nil), do: nil + defp format_caps([]), do: nil + + defp format_caps(caps) do + caps + |> Enum.map(fn + %{resource: r, verb: v} -> "#{r}.#{v}" + other -> inspect(other) + end) + |> Enum.join(", ") + end + + defp format_tags(nil), do: nil + defp format_tags([]), do: nil + defp format_tags(tags) when is_list(tags), do: Enum.join(tags, ", ") + defp format_tags(_), do: nil +end diff --git a/lib/egghead/irc/wire.ex b/lib/egghead/irc/wire.ex new file mode 100644 index 0000000..8c15317 --- /dev/null +++ b/lib/egghead/irc/wire.ex @@ -0,0 +1,119 @@ +defmodule Egghead.IRC.Wire do + @moduledoc """ + Low-level wire emission helpers — encoding `%Protocol.Message{}` + structs and writing them to a Thousand Island socket. + + Pulled out of `Connection` so the per-connection module isn't + carrying the IRC line shaping logic. Higher-level helpers + (PRIVMSG / NOTICE / CTCP ACTION) take the channel name and tags as + arguments rather than reaching into connection state — channel + aliasing and IRCv3 cap negotiation stay in `Connection` / + `Channels`. + """ + + require Logger + + alias Egghead.IRC.Protocol + + @ctcp_delim <<1>> + + @doc "Write a pre-encoded iodata line to the socket." + def write(socket, iodata) do + case ThousandIsland.Socket.send(socket, iodata) do + :ok -> :ok + {:error, reason} -> Logger.debug("IRC send failed: #{inspect(reason)}") + end + end + + @doc "Encode a `%Protocol.Message{}` and write it." + def send_message(socket, %Protocol.Message{} = msg) do + write(socket, Protocol.encode(msg)) + end + + @doc """ + Send a PRIVMSG, splitting `content` on newlines so multi-paragraph + messages don't drop content. `tags` defaults to `%{}` (no IRCv3 + tags); pass the result of `time_tag/2` to add `@time=`. + """ + def send_privmsg(socket, from_nick, channel, content, tags \\ %{}) do + content + |> String.split(~r/\r?\n/) + |> Enum.reject(&(&1 == "")) + |> Enum.each(fn line -> + write( + socket, + Protocol.encode( + tags: tags, + prefix: from_nick, + command: "PRIVMSG", + params: [channel], + trailing: line + ) + ) + end) + end + + @doc """ + Send a CTCP ACTION (the `/me` style line — most clients render as + `* nick text`). `tags` follows the same convention as `send_privmsg`. + """ + def send_action(socket, from_nick, channel, text, tags \\ %{}) do + write( + socket, + Protocol.encode( + tags: tags, + prefix: from_nick, + command: "PRIVMSG", + params: [channel], + trailing: @ctcp_delim <> "ACTION " <> text <> @ctcp_delim + ) + ) + end + + @doc """ + Send a NOTICE from `server` to `target` (channel or nick). Splits + `text` on newlines. + """ + def send_notice(socket, server, target, text, tags \\ %{}) do + text + |> String.split(~r/\r?\n/) + |> Enum.reject(&(&1 == "")) + |> Enum.each(fn line -> + write( + socket, + Protocol.encode( + tags: tags, + prefix: server, + command: "NOTICE", + params: [target], + trailing: line + ) + ) + end) + end + + @doc """ + IRCv3 `server-time` tag map. Returns `%{}` if the client didn't + negotiate the cap (so the encoder emits no tag prefix at all), + else `%{"time" => ISO-8601}`. Pass an explicit `DateTime` for + historical messages (scrollback replay); otherwise defaults to now. + """ + def time_tag(caps, dt \\ nil) do + if MapSet.member?(caps, "server-time") do + iso = (dt || DateTime.utc_now()) |> DateTime.to_iso8601() + %{"time" => iso} + else + %{} + end + end + + @doc "RFC-style hostmask prefix `nick!user@host` — falls back to nick if user is nil." + def prefix(nick, user, host), do: "#{nick}!#{user || nick}@#{host}" + + @doc """ + Synthetic prefix for events sourced from agents (no real socket). + `nick!egghead@server` is recognizable, validates as a hostmask, and + makes it clear this isn't a human peer. + """ + def agent_prefix(nick, server), do: "#{nick}!egghead@#{server}" +end diff --git a/lib/egghead/node.ex b/lib/egghead/node.ex index fbc6b5f..d079883 100644 --- a/lib/egghead/node.ex +++ b/lib/egghead/node.ex @@ -188,29 +188,31 @@ defmodule Egghead.Node do @doc false # Public for tests. Resolves the highest-precedence server target. + # + # `EGGHEAD_SERVER` is treated as an absolute directive: if the user + # set it, only that target is consulted, and an unreachable host + # returns `:none` — never falls through to the local-epmd probe. + # Otherwise that fallthrough would silently attach to a *different* + # server (the one running on this box) than the operator named, which + # is actively wrong for cross-host attachment. def discover_server do - with :none <- discover_from_env(), - :none <- discover_from_config(), - :none <- discover_from_epmd(~c"localhost", :shortnames) do - :none + case env_target() do + {:ok, host} -> + discover_from_epmd(to_charlist(host), :longnames, host) + + :none -> + with :none <- discover_from_config(), + :none <- discover_from_epmd(~c"localhost", :shortnames) do + :none + end end end - defp discover_from_env do + defp env_target do case System.get_env("EGGHEAD_SERVER") do - nil -> - :none - - "" -> - :none - - host -> - # EGGHEAD_SERVER points at a remote host. Probe epmd there; - # if `egghead_server` is registered, return its longname. - # Falling through to :none lets the caller drop to standalone - # rather than hang indefinitely on an unreachable host. - host = String.trim(host) - discover_from_epmd(to_charlist(host), :longnames, host) + nil -> :none + "" -> :none + host -> {:ok, String.trim(host)} end end diff --git a/mix.exs b/mix.exs index 478a5d7..c1fcee5 100644 --- a/mix.exs +++ b/mix.exs @@ -276,6 +276,7 @@ defmodule Egghead.MixProject do {:jason, "~> 1.4"}, {:phoenix_pubsub, "~> 2.1"}, {:bandit, "~> 1.6"}, + {:thousand_island, "~> 1.3"}, {:plug, "~> 1.16"}, {:req, "~> 0.5"}, {:phoenix, "~> 1.7"}, diff --git a/site/content/guides/irc.md b/site/content/guides/irc.md new file mode 100644 index 0000000..8769f55 --- /dev/null +++ b/site/content/guides/irc.md @@ -0,0 +1,403 @@ +--- +title: IRC server +section: Integrations +weight: 32 +summary: Egghead's chat rooms exposed as an IRC server, so your daily IRC client can talk to your agents. +--- + +Egghead ships an IRC server on the same node as the web UI and the +MCP endpoint. Channels are chat rooms, nicks are agents, and the +slash-command palette you already know from the TUI is the verb set +on the wire. Point ERC, irssi, weechat, or any other RFC 2812-ish +client at `localhost:6667` and you are in. + +The reason this exists is that IRC clients are very good at the +shape Egghead's chat rooms already have: a long-lived shared +buffer, terse line-at-a-time messages, multiple participants, a +slash-command convention. The TUI and the web UI already cover +chat — IRC is a third surface for people who already live in an +IRC client and would rather keep their agents in the same buffer +ring as everything else they read all day. Scrollback, +ping-on-mention, message search, log archiving, away tracking, +and the other affordances IRC clients have refined for thirty +years come along for the ride. + +The wire is the integration. Anything that speaks RFC 2812 +becomes a viable Egghead client without further work on our side. + +## Starting the server + +The IRC server starts by default whenever `egghead serve` runs. +There is no separate command, no extra flag, and no config block +required for the loopback case: + +```bash +egghead serve +``` + +You should see a log line like: + +``` +IRC server listening on 127.0.0.1:6667 +``` + +To turn it off, either pass `--no-irc` to `egghead serve`, set +`EGGHEAD_IRC=false` in the environment, or set +`config :egghead, :start_irc, false` at compile time. The shape +mirrors the web server's `--no-web` switch — IRC is just another +network surface attached to the same supervision tree, not a +separate program. + +## Connecting + +There is one Egghead node and one IRC server per host. Connect +the way you would to any other ircd; the canonical examples: + +### ERC (Emacs) + +``` +M-x erc RET 127.0.0.1 RET 6667 RET RET +``` + +### irssi + +``` +/connect 127.0.0.1 6667 +``` + +### weechat + +``` +/server add egghead 127.0.0.1/6667 +/connect egghead +``` + +### Command-line probe + +```bash +nc 127.0.0.1 6667 +``` + +If you just want to see registration handshake at the line +level — `NICK`, `USER`, `001 RPL_WELCOME`, the `005 RPL_ISUPPORT` +chain — `nc` (or any other raw TCP tool) is the fastest way. + +## Channels are rooms + +Joining a channel joins the underlying chat room. A single special +case is worth knowing up front: + +``` +/join #default +``` + +`#default` is a per-connection alias that resolves to whatever your +configured `default_room:` actually is. The server echoes the join +back as `#default` (not the canonical name) so strict clients like +ERC actually open a buffer for it. Every other channel name maps +directly: `/join #architecture-sync` opens or joins the +`architecture-sync` room. + +``` +/join #architecture-sync (creates or joins) +/part #architecture-sync (leaves the room from this connection) +/list (lists every live room as channels) +/names #architecture-sync (roster: agents in the room + you) +``` + +Rooms are documented at length in +[Chat rooms]({{< ref "chat-rooms" >}}); the IRC mapping is a +straight projection. Joining a saved transcript is the same +operation as in the TUI: `/join #chat-foo` rehydrates if a +`class: transcript` record exists at that id. + +## Nicks are agents + +A record's id is its pathname inside the records directory, minus +the `.md` extension. An agent record at `~/.egghead/scout.md` has +id `scout`; one at `~/.egghead/agents/scout.md` has id +`agents/scout`. Either layout is fine — Egghead does not impose a +directory convention. + +IRC nicks, on the other hand, are constrained by the +[RFC 2812 §2.3.1 nickname grammar](https://datatracker.ietf.org/doc/html/rfc2812#section-2.3.1): +no slashes, ASCII-ish, no leading digits. So the server projects +ids onto the IRC nick form before they hit the wire. The +projection takes the last path segment, replaces invalid +characters with `_`, and truncates to 30 characters: + +| Record id | IRC nick | +|--------------------|------------| +| `scout` | `scout` | +| `agents/scout` | `scout` | +| `agents/the.judge` | `the_judge`| +| `cassowary` | `cassowary`| + +The projection is what your client sees and addresses; the full +record id never appears on the wire. Two records whose paths +project to the same nick is a collision the projection layer does +not currently resolve, so if you have both `agents/scout.md` and +`scout.md` in your store, expect surprises and rename one. + +The server also marks agent nicks as bots in clients that support +[RPL_WHOISBOT (335)](https://modern.ircdocs.horse/#rplwhoisbot-335). +Clients that don't support it just see a normal user. + +## Addressing in channel + +The four addressing modes from +[Chat rooms]({{< ref "chat-rooms" >}}) work unchanged. The +coordinator does not care that the message came in over IRC. + +``` +Anyone have context on the auth middleware? +``` + +Open message: the structural filter and TF-IDF score determine who +responds, and quiet agents stay quiet by default. + +``` +@scout what's the rate limit on api.stripe.com? +``` + +Direct address: only `scout` is prompted. Fuzzy match still +applies (`@scout`, `@agents/scout`, and `@scoot` all land on the +same agent). + +``` +@everyone what's your best guess on the deadlock? +``` + +Everyone responds in series; `/pass` is rejected. + +``` +@jam what's your angle on this? +``` + +Everyone responds in parallel without seeing each other's drafts. + +`@`-mentions inside an IRC `PRIVMSG` use the agent's IRC nick +(without slash namespacing). The coordinator's rules are otherwise +identical. + +## Direct messages to an agent + +``` +/msg scout summarize the last week of meta/session-log +``` + +A `PRIVMSG` to an agent nick is a 1:1 prompt — `Egghead.prompt/3`, +not a chat-room message. There is no shared transcript, no other +agents see it, and the response comes back as a `PRIVMSG` from +that agent's nick. This is the IRC analogue of `/handoff`-less +ephemeral consultation: handy for a quick lookup that does not +need to clutter a channel. + +## Slash-command palette as IRC verbs + +The TUI's slash commands are mapped onto native IRC verbs over the +wire. ERC's `/handoff scout` sends `HANDOFF scout`; irssi's +`/save` sends `SAVE`. You get the same muscle memory across both +surfaces. + +| IRC verb | TUI command | Effect | +|--------------|----------------------|--------| +| `SAVE` | `/save` | Persist the room as a `class: transcript` record. | +| `CONTINUE` | `/continue` | Reset the activation budget. | +| `HALT` | `/halt` | Stop the room without saving. | +| `MUTE ` | `/mute ` | Suppress an agent's activation in this room. | +| `UNMUTE ` | `/unmute ` | Lift the mute. | +| `HANDOFF `| `/handoff ` | Summarize and clear the agent's session. | +| `CONTEXT` | (TUI: per-agent WHOIS) | Print a per-agent context-window snapshot for the room. | + +Each verb resolves the target room from the channel buffer it was +issued in; no `#channel` argument is required when there is no +ambiguity. If your client is somehow not in any channel and you +fire a bare `SAVE`, the server replies with the standard +[461 ERR_NEEDMOREPARAMS](https://modern.ircdocs.horse/#errneedmoreparams-461). + +`HANDOFF` runs a multi-second LLM summarization call; the verb +returns immediately with `Handing off scout…` and posts a +`NOTICE` when the summary completes. + +## Channel ops: KICK and INVITE + +``` +/kick #architecture-sync scout :stale context +/invite scout #architecture-sync +``` + +These are the IRC bindings for `Room.kick/2` and `Room.invite/2`, +not the standard "channel ops" verbs from RFC 2812 — Egghead does +not implement channel modes or oper auth, and there are no +privilege checks on either operation. The mapping is documented +in [Chat rooms]({{< ref "chat-rooms" >}}#mute-versus-kick). + +`KICK` removes an agent from the roster and drops its per-room +session; `INVITE` adds an agent and starts its process if it is +not running. The agent process keeps running for any other rooms +it belongs to. + +## WHOIS + +``` +/whois scout +``` + +`WHOIS` against an agent nick packs the model and current context +percentage into the `RPL_WHOISUSER (311)` realname field, the +agent id and tags and capabilities into `RPL_WHOISSERVER (312)`, +joined channels into `RPL_WHOISCHANNELS (319)`, and emits +`RPL_WHOISBOT (335)` so modern clients render the bot marker. + +The reason metadata lives in the realname and server-info fields, +rather than in the more obvious `RPL_WHOISSPECIAL (320)`, is that +several major clients hardcode 320 as "is identified to services" +and ignore the trailing text. Packing into 311/312 means the +information actually shows up. + +## CHATHISTORY scrollback + +If your client negotiates the IRCv3 +[server-time](https://ircv3.net/specs/extensions/server-time) and +[batch](https://ircv3.net/specs/extensions/batch) capabilities, +joining a channel replays the most recent fifty messages from the +room transcript with their original timestamps. That makes +scrollback feel real: messages render at the time they were +spoken, not at the time you joined. + +For deeper scrollback, the server speaks the +[CHATHISTORY](https://ircv3.net/specs/extensions/chathistory) +verb (LATEST, BEFORE, AFTER, AROUND, BETWEEN). ERC, weechat with +the IRCv3 plugin, and most modern web clients drive it +automatically as you scroll up. The cap is advertised in +`RPL_ISUPPORT` as `CHATHISTORY=`. + +A note on capabilities: clients that did not negotiate +`server-time` get no scrollback replay on `JOIN`, because messages +without a timestamp would render at "now" and look like a +confusing burst of duplicates. That is by design, not a bug; the +client is opting out of the feature it would need to render the +replay correctly. + +## Configuration + +The full `irc:` block in `config.yml`: + +```yaml +irc: + port: 6667 # default 6667 + bind: 127.0.0.1 # default 127.0.0.1 + hostname: irc.local # default: gethostname() + password: "{env:EGGHEAD_IRC_PASSWORD}" # optional shared password +``` + +Every key is optional. With no `irc:` block at all, the server +starts on `127.0.0.1:6667` with no auth and a hostname derived +from the local system. The `{env:VAR}` substitution behaves the +same way it does in the rest of the configuration file (see +[Configuration]({{< ref "configuration" >}})). + +Per-invocation overrides: + +| Override | Purpose | +|---------------------------|---------| +| `--irc-port ` | Override `irc.port` for one `egghead serve`. | +| `--no-irc` | Disable the IRC server for one `egghead serve`. | +| `EGGHEAD_IRC_PORT=` | Same as `--irc-port`. | +| `EGGHEAD_IRC_BIND=` | Override `irc.bind`. | +| `EGGHEAD_IRC=false` | Disable the IRC server. | + +If a `password` is configured, clients must `PASS` it during +registration before `NICK`/`USER` (see +[RFC 2812 §3.1.1](https://datatracker.ietf.org/doc/html/rfc2812#section-3.1.1)). +Most clients have a server-password setting; ERC asks for it at +connect time, irssi takes it as `-pw`, weechat as `password=`. + +## Security + +The same posture as the HTTP and MCP surfaces: Egghead does no +per-caller authentication of its own. An IRC client that can reach +the server can read every channel it joins and prompt every +agent. The optional `irc.password` is a single shared secret; it +is `PASS`-style auth, not nickserv, and there are no per-user +ACLs. + +There are three safe deployment shapes, in order of preference: + +1. **Loopback only** (the default): `bind: 127.0.0.1`. The IRC + server is reachable only from the same machine. Right for a + personal laptop. +2. **Trusted network** (tailnet, private VPC, LAN you own): + `bind: 0.0.0.0` with the network itself doing + authentication. The same shape covered for the web UI in + [Running a node]({{< ref "running-a-node" >}}#bind-and-exposure). +3. **Loopback plus a TLS-terminating reverse proxy in front of + IRC**: possible but not common. The IRC ecosystem usually + handles TLS in-protocol on port 6697 instead, which Egghead + does not currently support — see "Limitations" below. + +Don't bind `0.0.0.0` to a public interface without one of the +above. Without authentication, an unauthenticated public IRC +endpoint is equivalent to publishing read/write access to your +chat rooms and your agents to anyone who finds the port. + +## IRCv3 capabilities + +The server advertises the following caps via `CAP LS`: + +| Cap | Effect | +|----------------|--------| +| `server-time` | Outbound messages carry an `@time=` tag with the original timestamp. | +| `batch` | Multi-message bursts (CHATHISTORY responses, JOIN replay) are wrapped in `BATCH` envelopes so clients distinguish history from live traffic. | +| `chathistory` | Server speaks the `CHATHISTORY` verb. | + +Anything else clients ask for via `CAP REQ` is rejected with +`CAP NAK`. The server is not pretending to be a full IRCv3 +implementation; it negotiates exactly the caps that make +scrollback work correctly and stops there. + +## Single-user today + +Egghead is a single-user system at the moment, and the IRC server +inherits that. Every connection submits messages as the system +user (`Egghead.User.current/0`); a second connection from a +different human would show up as the same speaker in the +transcript. Multi-user identity is a deliberate non-feature +today, and nothing in the wire protocol forecloses adding it +later when two humans actually want to share an instance. + +In practice this means: today, run one IRC connection per Egghead +node. Multiple connections work and the wire protocol is correct, +but the speaker identity is shared. + +## Limitations + +A few things that exist on the public IRC plane and do not exist +here: + +- **No TLS on 6697.** Plaintext only. Loopback or a trusted + network is the deployment story. ThousandIsland supports SSL + out of the box, so this is mostly a config wiring exercise if + it becomes important. +- **No federation, services, oper auth, channel modes, or + K-lines.** Egghead's IRC is an interface to one instance, not a + network. The IRCd-as-distributed-system surface is irrelevant + here. + +## See also + +- [Chat rooms]({{< ref "chat-rooms" >}}) covers the underlying + coordination model — addressing, activation, the turn budget, + mute versus kick — that the IRC layer projects onto the wire. +- [Configuration]({{< ref "configuration" >}}) covers the full + `config.yml` schema and `{env:VAR}` substitution. +- [Running a node]({{< ref "running-a-node" >}}) covers process + supervision, network exposure, and the bind-and-exposure + reasoning that applies equally to the IRC port. +- [MCP server]({{< ref "mcp" >}}) is the other integration + surface on the same node — same posture, different protocol. +- [RFC 2812](https://datatracker.ietf.org/doc/html/rfc2812) and + the [Modern IRC client protocol](https://modern.ircdocs.horse/) + are the authoritative references for the wire protocol and the + numeric reply codes. diff --git a/test/egghead/config_irc_test.exs b/test/egghead/config_irc_test.exs new file mode 100644 index 0000000..4451ead --- /dev/null +++ b/test/egghead/config_irc_test.exs @@ -0,0 +1,112 @@ +defmodule Egghead.ConfigIRCTest do + @moduledoc """ + Locks in the always-on-with-defaults posture for the `irc:` config + block. Mirrors how `web:` works: zero config produces a complete, + bootable map; the block in YAML overrides individual fields. + """ + + use ExUnit.Case, async: false + + alias Egghead.Config + + @moduletag :tmp_dir + + setup %{tmp_dir: dir} do + path = Path.join(dir, "config.yml") + previous = System.get_env("EGGHEAD_CONFIG") + System.put_env("EGGHEAD_CONFIG", path) + + on_exit(fn -> + if previous, + do: System.put_env("EGGHEAD_CONFIG", previous), + else: System.delete_env("EGGHEAD_CONFIG") + end) + + {:ok, path: path} + end + + describe "defaults" do + test "default struct has a populated irc map" do + assert %Config{irc: irc} = %Config{} + assert irc.port == 6667 + assert irc.bind == "127.0.0.1" + assert irc.hostname == nil + assert irc.password == nil + end + + test "yaml without an irc block yields the default irc map", %{path: path} do + File.write!(path, "records_dir: ~/.egghead\n") + + assert {:ok, %Config{irc: irc}} = Config.load() + assert irc.port == 6667 + assert irc.bind == "127.0.0.1" + assert irc.hostname == nil + assert irc.password == nil + end + end + + describe "yaml parsing" do + test "parses port and bind", %{path: path} do + File.write!(path, """ + irc: + port: 6697 + bind: 0.0.0.0 + """) + + assert {:ok, %Config{irc: irc}} = Config.load() + assert irc.port == 6697 + assert irc.bind == "0.0.0.0" + end + + test "parses hostname and password", %{path: path} do + File.write!(path, """ + irc: + hostname: chat.example.com + password: hunter2 + """) + + assert {:ok, %Config{irc: irc}} = Config.load() + assert irc.hostname == "chat.example.com" + assert irc.password == "hunter2" + # Defaults preserved for fields not in YAML. + assert irc.port == 6667 + assert irc.bind == "127.0.0.1" + end + + test "resolves {env:VAR} in password", %{path: path} do + System.put_env("EGGHEAD_TEST_IRC_PW", "from-env") + on_exit(fn -> System.delete_env("EGGHEAD_TEST_IRC_PW") end) + + File.write!(path, """ + irc: + password: "{env:EGGHEAD_TEST_IRC_PW}" + """) + + assert {:ok, %Config{irc: irc}} = Config.load() + assert irc.password == "from-env" + end + end + + describe "yaml round-trip" do + test "saving + reloading preserves non-default irc fields", %{path: _path} do + original = %Config{ + irc: %{port: 6697, bind: "0.0.0.0", hostname: "irc.example", password: "secret"} + } + + :ok = Config.save(original) + + assert {:ok, %Config{irc: irc}} = Config.load() + assert irc.port == 6697 + assert irc.bind == "0.0.0.0" + assert irc.hostname == "irc.example" + assert irc.password == "secret" + end + + test "default irc block is omitted from emitted yaml", %{path: path} do + :ok = Config.save(%Config{}) + + content = File.read!(path) + refute content =~ "irc:" + end + end +end diff --git a/test/egghead/irc/action_events_test.exs b/test/egghead/irc/action_events_test.exs new file mode 100644 index 0000000..65b4ad3 --- /dev/null +++ b/test/egghead/irc/action_events_test.exs @@ -0,0 +1,392 @@ +defmodule Egghead.IRC.ActionEventsTest do + @moduledoc """ + Agent action events surfaced over IRC. Each test drives the room + directly via PubSub broadcasts (the actual coordinator/agent path is + too heavy to spin up here) and asserts the IRC connection translates + to the right wire shape: CTCP ACTION for /pass, tool calls, and tool + denials; NOTICE for system notices and halt/continue; synthetic + JOIN/PART for agent roster changes; paragraph-buffered PRIVMSG for + mid-stream flushes. + + Setup mirrors `server_integration_test.exs` — boot the IRC.Server on + an OS-assigned port, connect via `:gen_tcp`, register, JOIN a room. + Then send the room a PubSub event and assert the wire output. + """ + + use ExUnit.Case + + alias Egghead.Chat.Room + + @ctcp_action_marker <<1>> <> "ACTION " + + setup_all do + case Phoenix.PubSub.Supervisor.start_link(name: Egghead.PubSub) do + {:ok, _} -> :ok + {:error, {:already_started, _}} -> :ok + end + + :ok + end + + setup do + port = free_port() + + opts = [ + config: %{ + port: port, + bind: "127.0.0.1", + hostname: "test.irc.local", + password: nil + } + ] + + start_supervised!({Egghead.IRC.Server, opts}) + + room_id = "m2-#{:erlang.unique_integer([:positive])}" + {:ok, _} = Room.start_link(id: room_id) + on_exit(fn -> if Room.exists?(room_id), do: Room.stop(room_id) end) + + sock = connect(port) + register(sock, "watcher") + send_line(sock, "JOIN ##{room_id}") + _ = recv_until(sock, "366", 2000) + + {:ok, sock: sock, room_id: room_id, port: port} + end + + describe "/pass" do + test "agent_passed broadcast becomes CTCP ACTION", %{sock: sock, room_id: room_id} do + Phoenix.PubSub.broadcast( + Egghead.PubSub, + Room.topic(room_id), + {:agent_passed, "agents/scout"} + ) + + line = recv_one(sock, 1500) + assert line =~ ~r/^:scout PRIVMSG ##{room_id} :/ + assert line =~ "\x01ACTION " + assert String.ends_with?(line, "\x01") + end + end + + describe "tool calls" do + test "agent_tool_call becomes CTCP ACTION with key=value summary", %{ + sock: sock, + room_id: room_id + } do + Phoenix.PubSub.broadcast( + Egghead.PubSub, + Room.topic(room_id), + {:agent_tool_call, room_id, "agents/scout", "read_file", %{"path" => "/tmp/foo.md"}} + ) + + line = recv_one(sock, 1500) + assert line =~ "ACTION uses read_file" + assert line =~ "path=/tmp/foo.md" + end + + test "long tool input values are truncated", %{sock: sock, room_id: room_id} do + long = String.duplicate("a", 80) + + Phoenix.PubSub.broadcast( + Egghead.PubSub, + Room.topic(room_id), + {:agent_tool_call, room_id, "agents/scout", "search", %{"query" => long}} + ) + + line = recv_one(sock, 1500) + assert line =~ "query=" + assert line =~ "..." + # 40-char limit: 37 chars + "..." prefix is fine + refute line =~ String.duplicate("a", 50) + end + + test "empty tool input renders just the verb", %{sock: sock, room_id: room_id} do + Phoenix.PubSub.broadcast( + Egghead.PubSub, + Room.topic(room_id), + {:agent_tool_call, room_id, "agents/scout", "list", %{}} + ) + + line = recv_one(sock, 1500) + assert line =~ "ACTION uses list\x01" + end + end + + describe "tool denials" do + test "agent_tool_denied becomes CTCP ACTION with the denial message", %{ + sock: sock, + room_id: room_id + } do + denial = %Egghead.Capability.Denial{ + code: :no_grant, + agent_id: "agents/scout", + tool: "net_get", + message: "no grant for net.get on api.example.com", + held: [] + } + + Phoenix.PubSub.broadcast( + Egghead.PubSub, + Room.topic(room_id), + {:agent_tool_denied, room_id, "agents/scout", "net_get", %{"url" => "..."}, denial} + ) + + line = recv_one(sock, 1500) + assert line =~ ~r/^:scout PRIVMSG ##{room_id} :/ + assert line =~ "ACTION was denied net_get" + assert line =~ "no grant for net.get on api.example.com" + end + + test "denial with nil/missing message falls back to a generic reason", %{ + sock: sock, + room_id: room_id + } do + Phoenix.PubSub.broadcast( + Egghead.PubSub, + Room.topic(room_id), + {:agent_tool_denied, room_id, "agents/scout", "tool", %{}, nil} + ) + + line = recv_one(sock, 1500) + assert line =~ "ACTION was denied tool: denied" + end + end + + describe "agent join/leave" do + test "agent_joined broadcasts a synthetic JOIN line for the agent", %{ + sock: sock, + room_id: room_id + } do + Phoenix.PubSub.broadcast( + Egghead.PubSub, + Room.topic(room_id), + {:agent_joined, "agents/scout"} + ) + + line = recv_one(sock, 1500) + assert line =~ ~r/^:scout!egghead@test\.irc\.local JOIN ##{room_id}/ + end + + test "agent_left broadcasts a synthetic PART", %{sock: sock, room_id: room_id} do + Phoenix.PubSub.broadcast( + Egghead.PubSub, + Room.topic(room_id), + {:agent_left, "agents/scout"} + ) + + line = recv_one(sock, 1500) + assert line =~ ~r/^:scout!egghead@test\.irc\.local PART ##{room_id}/ + end + end + + describe "system messages" do + test "system_notice becomes IRC NOTICE", %{sock: sock, room_id: room_id} do + Phoenix.PubSub.broadcast( + Egghead.PubSub, + Room.topic(room_id), + {:system_notice, "Scout muted"} + ) + + line = recv_one(sock, 1500) + assert line =~ ~r/^:test\.irc\.local NOTICE ##{room_id} :Scout muted/ + end + + test "multiline system_notice splits into multiple NOTICEs", %{sock: sock, room_id: room_id} do + Phoenix.PubSub.broadcast( + Egghead.PubSub, + Room.topic(room_id), + {:system_notice, "first line\nsecond line"} + ) + + l1 = recv_one(sock, 1500) + l2 = recv_one(sock, 1500) + assert l1 =~ ":first line" + assert l2 =~ ":second line" + end + + test "halted broadcasts a NOTICE", %{sock: sock, room_id: room_id} do + Phoenix.PubSub.broadcast(Egghead.PubSub, Room.topic(room_id), {:halted, room_id}) + + line = recv_one(sock, 1500) + assert line =~ "NOTICE ##{room_id} :Halted" + end + + test "continued with replays broadcasts a NOTICE", %{sock: sock, room_id: room_id} do + Phoenix.PubSub.broadcast( + Egghead.PubSub, + Room.topic(room_id), + {:continued, replayed: 3} + ) + + line = recv_one(sock, 1500) + assert line =~ "NOTICE ##{room_id} :Continuing" + assert line =~ "3 queued" + end + end + + describe "streaming buffer" do + test "delta without paragraph break does not emit", %{sock: sock, room_id: room_id} do + Phoenix.PubSub.broadcast( + Egghead.PubSub, + Room.topic(room_id), + {:agent_streaming, room_id, "agents/scout", "thinking..."} + ) + + assert {:error, :timeout} = :gen_tcp.recv(sock, 0, 200) + end + + test "two consecutive deltas with `\\n\\n` flush completed paragraph", %{ + sock: sock, + room_id: room_id + } do + Phoenix.PubSub.broadcast( + Egghead.PubSub, + Room.topic(room_id), + {:agent_streaming, room_id, "agents/scout", "Para 1\n\n"} + ) + + Phoenix.PubSub.broadcast( + Egghead.PubSub, + Room.topic(room_id), + {:agent_streaming, room_id, "agents/scout", "still typing"} + ) + + line = recv_one(sock, 1500) + assert line =~ ~r/^:scout PRIVMSG ##{room_id} :Para 1/ + assert {:error, :timeout} = :gen_tcp.recv(sock, 0, 200) + end + + test "agent_message after a partial stream emits only the unflushed tail", %{ + sock: sock, + room_id: room_id + } do + # Stream "Para 1\n\nPara 2" — paragraph 1 flushes, "Para 2" stays buffered. + Phoenix.PubSub.broadcast( + Egghead.PubSub, + Room.topic(room_id), + {:agent_streaming, room_id, "agents/scout", "Para 1\n\nPara 2"} + ) + + flushed = recv_one(sock, 1500) + assert flushed =~ ":Para 1" + + # Final message contains the entire content. Tail-only emit should + # produce just "Para 2". + msg = %Egghead.Chat.Room.Message{ + id: "msg-test", + room_id: room_id, + sender: %Egghead.Chat.Room.Sender{type: :agent, id: "agents/scout", name: "Scout"}, + content: "Para 1\n\nPara 2", + timestamp: DateTime.utc_now() + } + + Phoenix.PubSub.broadcast( + Egghead.PubSub, + Room.topic(room_id), + {:agent_message, msg} + ) + + tail = recv_one(sock, 1500) + assert tail =~ ":Para 2" + refute tail =~ "Para 1" + end + + test "agent_message with no prior streaming emits the full content", %{ + sock: sock, + room_id: room_id + } do + msg = %Egghead.Chat.Room.Message{ + id: "msg-test-2", + room_id: room_id, + sender: %Egghead.Chat.Room.Sender{type: :agent, id: "agents/scout", name: "Scout"}, + content: "Hello there", + timestamp: DateTime.utc_now() + } + + Phoenix.PubSub.broadcast( + Egghead.PubSub, + Room.topic(room_id), + {:agent_message, msg} + ) + + line = recv_one(sock, 1500) + assert line =~ ":Hello there" + end + end + + describe "multi-room routing" do + test "events from one room don't leak into another", %{sock: sock, room_id: room_id} do + other_id = "m2-other-#{:erlang.unique_integer([:positive])}" + {:ok, _} = Room.start_link(id: other_id) + on_exit(fn -> if Room.exists?(other_id), do: Room.stop(other_id) end) + + send_line(sock, "JOIN ##{other_id}") + _ = recv_until(sock, "366", 2000) + + # Broadcast :agent_passed only into `other_id`. + Phoenix.PubSub.broadcast( + Egghead.PubSub, + Room.topic(other_id), + {:agent_passed, "agents/scout"} + ) + + line = recv_one(sock, 1500) + # Action goes to the right channel. + assert line =~ ~r/PRIVMSG ##{other_id} / + refute line =~ ~r/PRIVMSG ##{room_id} / + end + end + + # --- helpers --- + + defp connect(port) do + {:ok, sock} = :gen_tcp.connect(~c"127.0.0.1", port, [:binary, active: false, packet: :line]) + sock + end + + defp send_line(sock, line) do + :ok = :gen_tcp.send(sock, [line, "\r\n"]) + end + + defp register(sock, nick) do + send_line(sock, "NICK #{nick}") + send_line(sock, "USER #{nick} 0 * :#{nick}") + _ = recv_until(sock, "005", 2000) + :ok + end + + defp recv_one(sock, timeout) do + {:ok, line} = :gen_tcp.recv(sock, 0, timeout) + String.trim_trailing(line, "\r\n") + end + + defp recv_until(sock, marker, timeout) do + deadline = System.monotonic_time(:millisecond) + timeout + do_recv_until(sock, marker, deadline, []) + end + + defp do_recv_until(sock, marker, deadline, acc) do + remaining = max(deadline - System.monotonic_time(:millisecond), 1) + + case :gen_tcp.recv(sock, 0, remaining) do + {:ok, data} -> + line = String.trim_trailing(data, "\r\n") + acc = [line | acc] + if line =~ marker, do: Enum.reverse(acc), else: do_recv_until(sock, marker, deadline, acc) + + {:error, _} -> + Enum.reverse(acc) + end + end + + defp free_port do + {:ok, l} = :gen_tcp.listen(0, [:binary, active: false]) + {:ok, port} = :inet.port(l) + :gen_tcp.close(l) + port + end + + # Unused helper retained for symmetry with other test modules. + _ = @ctcp_action_marker +end diff --git a/test/egghead/irc/auth_test.exs b/test/egghead/irc/auth_test.exs new file mode 100644 index 0000000..9449209 --- /dev/null +++ b/test/egghead/irc/auth_test.exs @@ -0,0 +1,86 @@ +defmodule Egghead.IRC.AuthTest do + @moduledoc """ + PASS authentication path. Lives in its own test module because it + needs an `Egghead.IRC.Server` configured with a password — the + `server_integration_test.exs` setup uses no auth. + """ + + use ExUnit.Case + + setup_all do + case Phoenix.PubSub.Supervisor.start_link(name: Egghead.PubSub) do + {:ok, _} -> :ok + {:error, {:already_started, _}} -> :ok + end + + :ok + end + + setup do + port = free_port() + + opts = [ + config: %{ + port: port, + bind: "127.0.0.1", + hostname: "auth.test.local", + password: "hunter2" + } + ] + + start_supervised!({Egghead.IRC.Server, opts}) + {:ok, port: port} + end + + test "wrong PASS gets 464 and disconnect", ctx do + sock = connect(ctx.port) + :ok = :gen_tcp.send(sock, "PASS wrong\r\n") + + {:ok, line} = :gen_tcp.recv(sock, 0, 2000) + assert line =~ "464" + assert {:error, :closed} = :gen_tcp.recv(sock, 0, 1000) + end + + test "correct PASS allows registration", ctx do + sock = connect(ctx.port) + :ok = :gen_tcp.send(sock, "PASS hunter2\r\n") + :ok = :gen_tcp.send(sock, "NICK frank\r\n") + :ok = :gen_tcp.send(sock, "USER frank 0 * :Frank\r\n") + + welcome = recv_until(sock, "001", 2000) + assert Enum.any?(welcome, &String.contains?(&1, "001 frank")) + + :gen_tcp.close(sock) + end + + defp connect(port) do + {:ok, sock} = :gen_tcp.connect(~c"127.0.0.1", port, [:binary, active: false, packet: :line]) + sock + end + + defp recv_until(sock, marker, timeout) do + deadline = System.monotonic_time(:millisecond) + timeout + do_recv_until(sock, marker, deadline, []) + end + + defp do_recv_until(sock, marker, deadline, acc) do + remaining = max(deadline - System.monotonic_time(:millisecond), 1) + + case :gen_tcp.recv(sock, 0, remaining) do + {:ok, data} -> + line = String.trim_trailing(data, "\r\n") + acc = [line | acc] + if line =~ marker, do: Enum.reverse(acc), else: do_recv_until(sock, marker, deadline, acc) + + {:error, _} -> + Enum.reverse(acc) + end + end + + defp free_port do + {:ok, l} = :gen_tcp.listen(0, [:binary, active: false]) + {:ok, port} = :inet.port(l) + :gen_tcp.close(l) + port + end +end diff --git a/test/egghead/irc/chathistory_test.exs b/test/egghead/irc/chathistory_test.exs new file mode 100644 index 0000000..416a8da --- /dev/null +++ b/test/egghead/irc/chathistory_test.exs @@ -0,0 +1,292 @@ +defmodule Egghead.IRC.ChathistoryTest do + @moduledoc """ + IRCv3 `chathistory` extension. Five subcommands (LATEST, BEFORE, + AFTER, AROUND, BETWEEN) for fetching arbitrary windows of room + history on demand. Responses come BATCH-wrapped with `chathistory` + type so clients can distinguish historical from live traffic. + + All tests open a connection that has negotiated `server-time`, + `batch`, and `chathistory` so the server emits the timestamps and + batch envelope. The complementary path (no caps → no special + handling) isn't tested separately because CHATHISTORY without + server-time + batch isn't a meaningful IRCv3 request. + """ + + use ExUnit.Case + + alias Egghead.Chat.Room + + setup_all do + case Phoenix.PubSub.Supervisor.start_link(name: Egghead.PubSub) do + {:ok, _} -> :ok + {:error, {:already_started, _}} -> :ok + end + + :ok + end + + setup do + port = free_port() + + opts = [ + config: %{ + port: port, + bind: "127.0.0.1", + hostname: "test.irc.local", + password: nil + } + ] + + start_supervised!({Egghead.IRC.Server, opts}) + + room_id = "ch-#{:erlang.unique_integer([:positive])}" + {:ok, _} = Room.start_link(id: room_id) + on_exit(fn -> if Room.exists?(room_id), do: Room.stop(room_id) end) + + seed_transcript(room_id, ["one", "two", "three", "four", "five"]) + {:ok, room_id: room_id, port: port} + end + + describe "registration advertising" do + test "ISUPPORT 005 includes CHATHISTORY=", %{port: port} do + # Plain registration — `open_with_caps/2` would consume the 005 + # line itself, so we drive NICK/USER directly and inspect the + # welcome burst here. + sock = connect(port) + send_line(sock, "NICK isuptest") + send_line(sock, "USER isuptest 0 * :isuptest") + + lines = recv_until(sock, " 005 isuptest", 2000) + + assert Enum.any?(lines, &String.contains?(&1, "CHATHISTORY=")), + "ISUPPORT must advertise CHATHISTORY (got: #{inspect(lines)})" + + :gen_tcp.close(sock) + end + + test "CAP LS advertises chathistory and batch", %{port: port} do + sock = connect(port) + send_line(sock, "CAP LS 302") + line = recv_one(sock, 1500) + assert line =~ "chathistory" + assert line =~ "batch" + :gen_tcp.close(sock) + end + end + + describe "CHATHISTORY LATEST" do + test "wraps response in a BATCH and returns the latest N messages", %{ + port: port, + room_id: room_id + } do + sock = open_with_caps(port, "lateaster") + + send_line(sock, "CHATHISTORY LATEST ##{room_id} * 3") + lines = drain_batch(sock, 1500) + + open = Enum.find(lines, &String.starts_with?(&1, ":test.irc.local BATCH +")) + close = Enum.find(lines, &(&1 =~ ~r/^:test\.irc\.local BATCH -/)) + assert open, "expected BATCH open line, got: #{inspect(lines)}" + assert close, "expected BATCH close line" + assert open =~ "chathistory ##{room_id}" + + msg_lines = Enum.filter(lines, &String.contains?(&1, "PRIVMSG")) + assert length(msg_lines) == 3 + # Latest 3 of [one, two, three, four, five] are three/four/five. + assert Enum.any?(msg_lines, &String.contains?(&1, "three")) + assert Enum.any?(msg_lines, &String.contains?(&1, "four")) + assert Enum.any?(msg_lines, &String.contains?(&1, "five")) + refute Enum.any?(msg_lines, &String.contains?(&1, "one")) + + assert Enum.all?(msg_lines, &String.starts_with?(&1, "@")) + assert Enum.all?(msg_lines, &String.contains?(&1, "batch=")) + assert Enum.all?(msg_lines, &String.contains?(&1, "time=")) + + :gen_tcp.close(sock) + end + + test "limit is clamped to CHATHISTORY=", %{port: port, room_id: room_id} do + sock = open_with_caps(port, "clampy") + + # Ask for 10000; we only have 5 in the seed and the cap is 100. + send_line(sock, "CHATHISTORY LATEST ##{room_id} * 10000") + lines = drain_batch(sock, 1500) + msg_lines = Enum.filter(lines, &String.contains?(&1, "PRIVMSG")) + assert length(msg_lines) == 5 + + :gen_tcp.close(sock) + end + end + + describe "CHATHISTORY BEFORE / AFTER" do + test "BEFORE returns messages strictly before timestamp", %{port: port, room_id: room_id} do + sock = open_with_caps(port, "beforey") + + # Pull all messages so we can identify a pivot timestamp. + send_line(sock, "CHATHISTORY LATEST ##{room_id} * 100") + latest = drain_batch(sock, 1500) + pivot_ts = extract_time_tag(Enum.at(Enum.filter(latest, &String.contains?(&1, "three")), 0)) + + send_line(sock, "CHATHISTORY BEFORE ##{room_id} timestamp=#{pivot_ts} 10") + before_lines = drain_batch(sock, 1500) |> Enum.filter(&String.contains?(&1, "PRIVMSG")) + + # "three" itself is NOT before its own timestamp (strictly less than) + refute Enum.any?(before_lines, &String.contains?(&1, "three")) + assert Enum.any?(before_lines, &String.contains?(&1, "one")) + assert Enum.any?(before_lines, &String.contains?(&1, "two")) + + :gen_tcp.close(sock) + end + + test "AFTER returns messages strictly after timestamp", %{port: port, room_id: room_id} do + sock = open_with_caps(port, "aftery") + + send_line(sock, "CHATHISTORY LATEST ##{room_id} * 100") + latest = drain_batch(sock, 1500) + pivot_ts = extract_time_tag(Enum.at(Enum.filter(latest, &String.contains?(&1, "three")), 0)) + + send_line(sock, "CHATHISTORY AFTER ##{room_id} timestamp=#{pivot_ts} 10") + after_lines = drain_batch(sock, 1500) |> Enum.filter(&String.contains?(&1, "PRIVMSG")) + + refute Enum.any?(after_lines, &String.contains?(&1, "three")) + assert Enum.any?(after_lines, &String.contains?(&1, "four")) + assert Enum.any?(after_lines, &String.contains?(&1, "five")) + + :gen_tcp.close(sock) + end + end + + describe "error paths" do + test "missing subcommand FAILs with NEED_MORE_PARAMS", %{port: port} do + sock = open_with_caps(port, "errsubcmd") + send_line(sock, "CHATHISTORY") + line = recv_one(sock, 1500) + assert line =~ "FAIL CHATHISTORY NEED_MORE_PARAMS" + :gen_tcp.close(sock) + end + + test "unknown subcommand FAILs", %{port: port, room_id: room_id} do + sock = open_with_caps(port, "errsub") + send_line(sock, "CHATHISTORY EVERYTHING ##{room_id} * 5") + line = recv_one(sock, 1500) + assert line =~ "FAIL CHATHISTORY UNKNOWN_COMMAND" + :gen_tcp.close(sock) + end + + test "BEFORE with malformed timestamp FAILs with INVALID_PARAMS", %{ + port: port, + room_id: room_id + } do + sock = open_with_caps(port, "errts") + send_line(sock, "CHATHISTORY BEFORE ##{room_id} not-a-timestamp 5") + line = recv_one(sock, 1500) + assert line =~ "FAIL CHATHISTORY INVALID_PARAMS" + :gen_tcp.close(sock) + end + + test "unknown channel FAILs with INVALID_TARGET", %{port: port} do + sock = open_with_caps(port, "errchan") + send_line(sock, "CHATHISTORY LATEST #nonexistent-channel * 5") + line = recv_one(sock, 1500) + assert line =~ "FAIL CHATHISTORY INVALID_TARGET" + :gen_tcp.close(sock) + end + end + + # --- helpers --- + + defp seed_transcript(room_id, contents) do + Enum.each(contents, fn c -> + :ok = Room.send_message(room_id, c) + # Tiny sleep so each message has a distinct timestamp — needed for + # the BEFORE/AFTER tests to pivot reliably. + :timer.sleep(5) + end) + end + + defp extract_time_tag(nil), do: raise("expected a timestamped line") + + defp extract_time_tag(line) do + case Regex.run(~r/time=([^;\s]+)/, line) do + [_, ts] -> ts + _ -> raise "no @time tag in: #{inspect(line)}" + end + end + + defp drain_batch(sock, timeout) do + deadline = System.monotonic_time(:millisecond) + timeout + do_drain_batch(sock, deadline, []) + end + + defp do_drain_batch(sock, deadline, acc) do + remaining = max(deadline - System.monotonic_time(:millisecond), 1) + + case :gen_tcp.recv(sock, 0, remaining) do + {:ok, data} -> + line = String.trim_trailing(data, "\r\n") + new_acc = [line | acc] + + if line =~ ~r/^:test\.irc\.local BATCH -/ do + Enum.reverse(new_acc) + else + do_drain_batch(sock, deadline, new_acc) + end + + {:error, _} -> + Enum.reverse(acc) + end + end + + defp open_with_caps(port, nick) do + sock = connect(port) + send_line(sock, "CAP LS 302") + _ = recv_one(sock, 1500) + send_line(sock, "CAP REQ :server-time batch chathistory") + _ = recv_one(sock, 1500) + send_line(sock, "CAP END") + send_line(sock, "NICK #{nick}") + send_line(sock, "USER #{nick} 0 * :#{nick}") + _ = recv_until(sock, " 005 #{nick}", 2000) + sock + end + + defp connect(port) do + {:ok, sock} = :gen_tcp.connect(~c"127.0.0.1", port, [:binary, active: false, packet: :line]) + sock + end + + defp send_line(sock, line) do + :ok = :gen_tcp.send(sock, [line, "\r\n"]) + end + + defp recv_one(sock, timeout) do + {:ok, line} = :gen_tcp.recv(sock, 0, timeout) + String.trim_trailing(line, "\r\n") + end + + defp recv_until(sock, marker, timeout) do + deadline = System.monotonic_time(:millisecond) + timeout + do_recv_until(sock, marker, deadline, []) + end + + defp do_recv_until(sock, marker, deadline, acc) do + remaining = max(deadline - System.monotonic_time(:millisecond), 1) + + case :gen_tcp.recv(sock, 0, remaining) do + {:ok, data} -> + line = String.trim_trailing(data, "\r\n") + acc = [line | acc] + if line =~ marker, do: Enum.reverse(acc), else: do_recv_until(sock, marker, deadline, acc) + + {:error, _} -> + Enum.reverse(acc) + end + end + + defp free_port do + {:ok, l} = :gen_tcp.listen(0, [:binary, active: false]) + {:ok, port} = :inet.port(l) + :gen_tcp.close(l) + port + end +end diff --git a/test/egghead/irc/dm_test.exs b/test/egghead/irc/dm_test.exs new file mode 100644 index 0000000..12d122f --- /dev/null +++ b/test/egghead/irc/dm_test.exs @@ -0,0 +1,138 @@ +defmodule Egghead.IRC.DMTest do + @moduledoc """ + Direct messages. `PRIVMSG :body` to an agent nick becomes an + ephemeral `Egghead.prompt/3` call; the response comes back as a + PRIVMSG from the agent to the asker. Cross-connection human-to-human + DM routing isn't wired yet. + + Most assertions cover the dispatch path (unknown nick → 401, known + human → "not wired" NOTICE, connection survives during the async + prompt). The successful round-trip path requires a real LLM and is + exercised in live use, not here. + """ + + use ExUnit.Case + + alias Egghead.Chat.Room + + setup_all do + case Phoenix.PubSub.Supervisor.start_link(name: Egghead.PubSub) do + {:ok, _} -> :ok + {:error, {:already_started, _}} -> :ok + end + + :ok + end + + setup do + port = free_port() + + opts = [ + config: %{ + port: port, + bind: "127.0.0.1", + hostname: "test.irc.local", + password: nil + } + ] + + start_supervised!({Egghead.IRC.Server, opts}) + + room_id = "m36-#{:erlang.unique_integer([:positive])}" + {:ok, _} = Room.start_link(id: room_id) + on_exit(fn -> if Room.exists?(room_id), do: Room.stop(room_id) end) + + sock = connect(port) + register(sock, "asker") + send_line(sock, "JOIN ##{room_id}") + _ = recv_until(sock, "366", 2000) + + {:ok, sock: sock, room_id: room_id, port: port} + end + + describe "DM target resolution" do + test "PRIVMSG to an unknown nick returns 401 ERR_NOSUCHNICK", %{sock: sock} do + send_line(sock, "PRIVMSG ghost :hi") + + line = recv_one(sock, 1500) + assert line =~ "401 asker ghost" + end + + test "PRIVMSG to another connected human nick returns the not-wired NOTICE", %{ + sock: sock, + port: port + } do + # Spin up a second connection so its nick is in the registry. + sock2 = connect(port) + register(sock2, "otherperson") + + send_line(sock, "PRIVMSG otherperson :hi") + + line = recv_one(sock, 1500) + assert line =~ "NOTICE asker :Human-to-human DMs" + + :gen_tcp.close(sock2) + end + + test "PRIVMSG to an agent nick does not return 401 (the dispatch ran)", %{sock: sock} do + # In test mode no real agents are registered, so resolve_anywhere + # returns :not_found — same as unknown nick. We can't unit-test + # the success path without an LLM, but we can lock in that the + # connection stays alive across the call (no crash from the Task + # spawn or socket plumbing). + send_line(sock, "PRIVMSG anything :hello") + _ = recv_one(sock, 1500) + + send_line(sock, "PING :alive-check") + pong = recv_one(sock, 1000) + assert pong =~ "PONG" + end + end + + defp connect(port) do + {:ok, sock} = :gen_tcp.connect(~c"127.0.0.1", port, [:binary, active: false, packet: :line]) + sock + end + + defp send_line(sock, line) do + :ok = :gen_tcp.send(sock, [line, "\r\n"]) + end + + defp register(sock, nick) do + send_line(sock, "NICK #{nick}") + send_line(sock, "USER #{nick} 0 * :#{nick}") + _ = recv_until(sock, "005", 2000) + :ok + end + + defp recv_one(sock, timeout) do + {:ok, line} = :gen_tcp.recv(sock, 0, timeout) + String.trim_trailing(line, "\r\n") + end + + defp recv_until(sock, marker, timeout) do + deadline = System.monotonic_time(:millisecond) + timeout + do_recv_until(sock, marker, deadline, []) + end + + defp do_recv_until(sock, marker, deadline, acc) do + remaining = max(deadline - System.monotonic_time(:millisecond), 1) + + case :gen_tcp.recv(sock, 0, remaining) do + {:ok, data} -> + line = String.trim_trailing(data, "\r\n") + acc = [line | acc] + if line =~ marker, do: Enum.reverse(acc), else: do_recv_until(sock, marker, deadline, acc) + + {:error, _} -> + Enum.reverse(acc) + end + end + + defp free_port do + {:ok, l} = :gen_tcp.listen(0, [:binary, active: false]) + {:ok, port} = :inet.port(l) + :gen_tcp.close(l) + port + end +end diff --git a/test/egghead/irc/nick_map_test.exs b/test/egghead/irc/nick_map_test.exs new file mode 100644 index 0000000..c624ef4 --- /dev/null +++ b/test/egghead/irc/nick_map_test.exs @@ -0,0 +1,85 @@ +defmodule Egghead.IRC.NickMapTest do + use ExUnit.Case, async: true + + doctest Egghead.IRC.NickMap + + alias Egghead.IRC.NickMap + + describe "id_to_nick/1" do + test "strips namespace prefix" do + assert NickMap.id_to_nick("agents/scout") == "scout" + assert NickMap.id_to_nick("users/mark") == "mark" + end + + test "no prefix passes through" do + assert NickMap.id_to_nick("scout") == "scout" + end + + test "replaces invalid characters with underscore" do + assert NickMap.id_to_nick("agents/the.judge") == "the_judge" + assert NickMap.id_to_nick("agents/foo bar") == "foo_bar" + end + + test "prepends underscore if first char is invalid" do + assert NickMap.id_to_nick("agents/3llen") == "_3llen" + end + + test "truncates to 30 chars" do + long = "agents/" <> String.duplicate("a", 60) + result = NickMap.id_to_nick(long) + assert String.length(result) == 30 + end + + test "empty basename becomes underscore" do + assert NickMap.id_to_nick("agents/") == "_" + end + end + + describe "valid_nick?/1" do + test "accepts plain alpha" do + assert NickMap.valid_nick?("scout") + assert NickMap.valid_nick?("Mark") + end + + test "accepts special starting chars" do + assert NickMap.valid_nick?("_bot") + assert NickMap.valid_nick?("[hi]") + end + + test "rejects digit-leading" do + refute NickMap.valid_nick?("3llen") + end + + test "rejects period" do + refute NickMap.valid_nick?("a.b") + end + + test "rejects empty" do + refute NickMap.valid_nick?("") + end + + test "rejects too long" do + refute NickMap.valid_nick?(String.duplicate("a", 31)) + end + + test "non-binary is invalid" do + refute NickMap.valid_nick?(:atom) + refute NickMap.valid_nick?(nil) + end + end + + describe "channel/room mapping" do + test "room_to_channel prefixes #" do + assert NickMap.room_to_channel("general") == "#general" + end + + test "channel_to_room strips channel prefix" do + assert NickMap.channel_to_room("#general") == "general" + assert NickMap.channel_to_room("&local") == "local" + end + + test "non-channel returns nil" do + assert NickMap.channel_to_room("nick") == nil + end + end +end diff --git a/test/egghead/irc/ops_commands_test.exs b/test/egghead/irc/ops_commands_test.exs new file mode 100644 index 0000000..2cebed3 --- /dev/null +++ b/test/egghead/irc/ops_commands_test.exs @@ -0,0 +1,235 @@ +defmodule Egghead.IRC.OpsCommandsTest do + @moduledoc """ + KICK, INVITE, WHOIS, MOTD, VERSION, TIME — the ops layer that rounds + out the IRC verb set so the server feels like a real IRC network and + not a toy. + + KICK and INVITE map directly to Room.leave/2 and Room.join/2 — no + channel-op gating since rooms are flat. WHOIS for an agent surfaces + model + context-window % + capabilities; for a connected human + returns a basic identity reply. + """ + + use ExUnit.Case + + alias Egghead.Chat.Room + + setup_all do + case Phoenix.PubSub.Supervisor.start_link(name: Egghead.PubSub) do + {:ok, _} -> :ok + {:error, {:already_started, _}} -> :ok + end + + :ok + end + + setup do + port = free_port() + + opts = [ + config: %{ + port: port, + bind: "127.0.0.1", + hostname: "test.irc.local", + password: nil + } + ] + + start_supervised!({Egghead.IRC.Server, opts}) + + room_id = "m35-#{:erlang.unique_integer([:positive])}" + {:ok, _} = Room.start_link(id: room_id) + on_exit(fn -> if Room.exists?(room_id), do: Room.stop(room_id) end) + + sock = connect(port) + register(sock, "opsy") + send_line(sock, "JOIN ##{room_id}") + _ = recv_until(sock, "366", 2000) + + {:ok, sock: sock, room_id: room_id, port: port} + end + + describe "KICK" do + test "KICK calls Room.leave for the resolved agent", %{ + sock: sock, + room_id: room_id + } do + :ok = Room.join(room_id, "agents/scout") + + Phoenix.PubSub.subscribe(Egghead.PubSub, Room.topic(room_id)) + send_line(sock, "KICK ##{room_id} scout") + + assert_receive {:agent_left, "agents/scout"}, 2000 + end + + test "KICK with unknown nick returns 401 ERR_NOSUCHNICK", %{sock: sock, room_id: room_id} do + send_line(sock, "KICK ##{room_id} ghost") + line = recv_one(sock, 1500) + assert line =~ "401 opsy ghost" + end + + test "KICK with missing args returns 461", %{sock: sock} do + send_line(sock, "KICK") + line = recv_one(sock, 1500) + assert line =~ "461 opsy KICK" + end + end + + describe "INVITE" do + test "INVITE with unknown agent returns 401", %{sock: sock, room_id: room_id} do + send_line(sock, "INVITE no-such-agent ##{room_id}") + line = recv_one(sock, 1500) + assert line =~ "401 opsy no-such-agent" + end + + test "INVITE against #default doesn't crash when caller hasn't joined #default", %{ + sock: sock, + room_id: room_id + } do + # Regression: `#default` is a per-connection alias resolved on + # JOIN, but `target_to_room_id/2` used to fall through to a + # literal "default" room id when the caller hadn't joined via + # that name. Subsequent Room.join("default", ...) crashed the + # connection with :no_proc. Now `target_to_room_id/2` resolves + # `#default` against `Egghead.default_room/0` even without a + # local alias. + saved = :persistent_term.get(:egghead_default_room, nil) + :persistent_term.put(:egghead_default_room, room_id) + + on_exit(fn -> + if saved, + do: :persistent_term.put(:egghead_default_room, saved), + else: :persistent_term.erase(:egghead_default_room) + end) + + send_line(sock, "INVITE no-such-agent #default") + + # Still 401 (agent doesn't exist) — but the connection must + # remain alive (no crash). + line = recv_one(sock, 1500) + assert line =~ "401 opsy no-such-agent" + + # Sanity: connection survives the call. + send_line(sock, "PING :alive") + pong = recv_one(sock, 1000) + assert pong =~ "PONG" + end + + # Skipping the 443-already-on-channel test: it requires a real + # registered agent process that `resolve_agent_anywhere/1` (which + # walks `Egghead.Agent.list_agents/0`) can find. Heavy to set up + # in unit-test mode without the record store. The unknown-agent + # test above exercises the resolve path; the 443 wire shape is + # validated by `protocol_test.exs` doctests on the encoder. + end + + describe "WHOIS" do + test "WHOIS for an unknown nick returns 401 then 318", %{sock: sock} do + send_line(sock, "WHOIS noone") + lines = recv_until(sock, " 318 opsy", 1500) + assert Enum.any?(lines, &String.contains?(&1, "401 opsy noone")) + assert Enum.any?(lines, &String.contains?(&1, "318 opsy noone")) + end + + test "WHOIS for the current connection (a human nick) returns 311 then 318", %{sock: sock} do + send_line(sock, "WHOIS opsy") + lines = recv_until(sock, " 318 opsy", 1500) + assert Enum.any?(lines, &String.contains?(&1, "311 opsy opsy ")) + assert Enum.any?(lines, &String.contains?(&1, "318 opsy opsy")) + end + + test "WHOIS for an agent does NOT emit 320 RPL_WHOISSPECIAL", %{sock: sock} do + # Regression: 320 has split semantics across IRCds — ERC and + # several other clients render it as "is identified to services" + # regardless of trailing text, so packing context/disposition/ + # capabilities into 320 lines silently lost the data. We now use + # 311 realname + 312 server-info + 335 RPL_WHOISBOT instead. + send_line(sock, "WHOIS opsy") + lines = recv_until(sock, " 318 opsy", 1500) + + refute Enum.any?(lines, &String.contains?(&1, " 320 ")), + "WHOIS should not emit 320 (got: #{inspect(lines)})" + end + end + + describe "MOTD" do + test "MOTD returns 375 / 372s / 376", %{sock: sock} do + send_line(sock, "MOTD") + lines = recv_until(sock, " 376 opsy", 1500) + + assert Enum.any?(lines, &String.contains?(&1, "375 opsy")) + assert Enum.any?(lines, &String.contains?(&1, "372 opsy")) + assert Enum.any?(lines, &String.contains?(&1, "376 opsy")) + assert Enum.any?(lines, &String.contains?(&1, "Welcome to Egghead")) + end + end + + describe "VERSION" do + test "VERSION returns 351 with the egghead version string", %{sock: sock} do + send_line(sock, "VERSION") + line = recv_one(sock, 1500) + assert line =~ "351 opsy" + assert line =~ "egghead" + end + end + + describe "TIME" do + test "TIME returns 391 with an ISO-8601 timestamp", %{sock: sock} do + send_line(sock, "TIME") + line = recv_one(sock, 1500) + assert line =~ "391 opsy" + # ISO-8601 has the year prefix and a `T` separator + assert line =~ ~r/202[0-9]-/ + assert line =~ "T" + end + end + + # --- helpers --- + + defp connect(port) do + {:ok, sock} = :gen_tcp.connect(~c"127.0.0.1", port, [:binary, active: false, packet: :line]) + sock + end + + defp send_line(sock, line) do + :ok = :gen_tcp.send(sock, [line, "\r\n"]) + end + + defp register(sock, nick) do + send_line(sock, "NICK #{nick}") + send_line(sock, "USER #{nick} 0 * :#{nick}") + _ = recv_until(sock, "005", 2000) + :ok + end + + defp recv_one(sock, timeout) do + {:ok, line} = :gen_tcp.recv(sock, 0, timeout) + String.trim_trailing(line, "\r\n") + end + + defp recv_until(sock, marker, timeout) do + deadline = System.monotonic_time(:millisecond) + timeout + do_recv_until(sock, marker, deadline, []) + end + + defp do_recv_until(sock, marker, deadline, acc) do + remaining = max(deadline - System.monotonic_time(:millisecond), 1) + + case :gen_tcp.recv(sock, 0, remaining) do + {:ok, data} -> + line = String.trim_trailing(data, "\r\n") + acc = [line | acc] + if line =~ marker, do: Enum.reverse(acc), else: do_recv_until(sock, marker, deadline, acc) + + {:error, _} -> + Enum.reverse(acc) + end + end + + defp free_port do + {:ok, l} = :gen_tcp.listen(0, [:binary, active: false]) + {:ok, port} = :inet.port(l) + :gen_tcp.close(l) + port + end +end diff --git a/test/egghead/irc/protocol_test.exs b/test/egghead/irc/protocol_test.exs new file mode 100644 index 0000000..444ff0a --- /dev/null +++ b/test/egghead/irc/protocol_test.exs @@ -0,0 +1,172 @@ +defmodule Egghead.IRC.ProtocolTest do + use ExUnit.Case, async: true + + doctest Egghead.IRC.Protocol + + alias Egghead.IRC.Protocol + alias Egghead.IRC.Protocol.Message + + describe "parse/1" do + test "bare command with no params" do + {:ok, m} = Protocol.parse("PING") + assert m.command == "PING" + assert m.params == [] + assert m.trailing == nil + assert m.prefix == nil + assert m.tags == %{} + end + + test "command with leading params and trailing" do + {:ok, m} = Protocol.parse("PRIVMSG #room :hello world") + assert m.command == "PRIVMSG" + assert m.params == ["#room"] + assert m.trailing == "hello world" + assert Protocol.Message.args(m) == ["#room", "hello world"] + end + + test "command with prefix" do + {:ok, m} = Protocol.parse(":scout!~bot@host PRIVMSG #room :hi") + assert m.prefix == "scout!~bot@host" + assert m.command == "PRIVMSG" + assert m.params == ["#room"] + assert m.trailing == "hi" + end + + test "command with multiple non-trailing params" do + {:ok, m} = Protocol.parse("USER bot 0 * :Bot Name") + assert m.params == ["bot", "0", "*"] + assert m.trailing == "Bot Name" + end + + test "uppercases command" do + {:ok, m} = Protocol.parse("privmsg #room :hi") + assert m.command == "PRIVMSG" + end + + test "preserves CRLF stripped lines" do + {:ok, m} = Protocol.parse("NICK foo\r") + assert m.command == "NICK" + assert m.params == ["foo"] + assert m.trailing == nil + end + + test "IRCv3 message tags" do + {:ok, m} = Protocol.parse("@time=2026-04-30;account=mark :u JOIN #room") + assert m.tags == %{"time" => "2026-04-30", "account" => "mark"} + assert m.prefix == "u" + assert m.command == "JOIN" + end + + test "tag without value" do + {:ok, m} = Protocol.parse("@bot PING :s") + assert m.tags == %{"bot" => true} + end + + test "trailing param with leading colon preserved" do + {:ok, m} = Protocol.parse("PRIVMSG #room ::wat") + assert m.params == ["#room"] + assert m.trailing == ":wat" + end + + test "empty input" do + assert {:error, :empty} = Protocol.parse("") + end + + test "tags-only with no command" do + assert {:error, :no_command} = Protocol.parse("@only=tags") + end + + test "prefix-only with no command" do + assert {:error, :no_command} = Protocol.parse(":only-prefix") + end + end + + describe "chunk/2" do + test "splits multiple complete lines" do + {msgs, rest} = Protocol.chunk("", "NICK a\r\nUSER b 0 * :B\r\n") + assert length(msgs) == 2 + assert Enum.map(msgs, & &1.command) == ["NICK", "USER"] + [_nick, user] = msgs + assert user.params == ["b", "0", "*"] + assert user.trailing == "B" + assert rest == "" + end + + test "buffers an incomplete trailing line" do + {msgs, rest} = Protocol.chunk("", "NICK a\r\nPART") + assert length(msgs) == 1 + assert rest == "PART" + end + + test "joins prior buffer with new bytes" do + {msgs1, rest1} = Protocol.chunk("", "PRIV") + assert msgs1 == [] + assert rest1 == "PRIV" + + {msgs2, rest2} = Protocol.chunk(rest1, "MSG #r :hi\r\n") + assert length(msgs2) == 1 + assert hd(msgs2).command == "PRIVMSG" + assert hd(msgs2).params == ["#r"] + assert hd(msgs2).trailing == "hi" + assert rest2 == "" + end + + test "skips empty lines from doubled CRLF" do + {msgs, _} = Protocol.chunk("", "PING\r\n\r\nPONG\r\n") + assert Enum.map(msgs, & &1.command) == ["PING", "PONG"] + end + end + + describe "encode/1" do + test "encodes a simple JOIN with no trailing" do + m = %Message{command: "JOIN", params: ["#room"]} + assert Protocol.encode(m) == "JOIN #room\r\n" + end + + test "encodes a numeric with prefix and trailing" do + m = %Message{prefix: "irc.local", command: "001", params: ["mark"], trailing: "Welcome"} + assert Protocol.encode(m) == ":irc.local 001 mark :Welcome\r\n" + end + + test "trailing param with spaces" do + m = %Message{command: "PRIVMSG", params: ["#room"], trailing: "hello world"} + assert Protocol.encode(m) == "PRIVMSG #room :hello world\r\n" + end + + test "empty trailing param" do + m = %Message{command: "PART", params: ["#room"], trailing: ""} + assert Protocol.encode(m) == "PART #room :\r\n" + end + + test "trailing param starting with colon" do + m = %Message{command: "PRIVMSG", params: ["#room"], trailing: ":wat"} + assert Protocol.encode(m) == "PRIVMSG #room ::wat\r\n" + end + + test "encodes IRCv3 tags" do + m = %Message{tags: %{"time" => "2026"}, command: "PING", trailing: "s"} + assert Protocol.encode(m) == "@time=2026 PING :s\r\n" + end + + test "round-trip parse → encode → parse stable" do + lines = [ + "PRIVMSG #room :hello world", + ":scout!u@h JOIN #general", + "USER bot 0 * :Bot Name", + "@time=2026 :s 001 mark :Welcome" + ] + + for line <- lines do + {:ok, m1} = Protocol.parse(line) + encoded = Protocol.encode(m1) + assert String.ends_with?(encoded, "\r\n") + {:ok, m2} = Protocol.parse(String.trim_trailing(encoded, "\r\n")) + assert m1.command == m2.command + assert m1.prefix == m2.prefix + assert m1.params == m2.params + assert m1.trailing == m2.trailing + assert m1.tags == m2.tags + end + end + end +end diff --git a/test/egghead/irc/server_integration_test.exs b/test/egghead/irc/server_integration_test.exs new file mode 100644 index 0000000..677a365 --- /dev/null +++ b/test/egghead/irc/server_integration_test.exs @@ -0,0 +1,456 @@ +defmodule Egghead.IRC.ServerIntegrationTest do + @moduledoc """ + End-to-end smoke tests against a live IRC listener. Boots the + `Egghead.IRC.Server` supervision subtree on an OS-assigned port, + connects via `:gen_tcp`, drives the protocol, and asserts on what + comes back over the wire. + + Acceptance test for the wire protocol layer — the unit tests in + `protocol_test.exs` cover encoding/parsing in isolation, but only + this test proves the registration handshake, JOIN/PART, and PRIVMSG + round-trip through real sockets. + """ + + use ExUnit.Case + + alias Egghead.Chat.Room + + setup_all do + case Phoenix.PubSub.Supervisor.start_link(name: Egghead.PubSub) do + {:ok, _} -> :ok + {:error, {:already_started, _}} -> :ok + end + + :ok + end + + setup do + port = free_port() + + opts = [ + config: %{ + port: port, + bind: "127.0.0.1", + hostname: "test.irc.local", + password: nil + } + ] + + start_supervised!({Egghead.IRC.Server, opts}) + + {:ok, port: port} + end + + describe "registration handshake" do + test "NICK + USER yields 001 RPL_WELCOME and ISUPPORT", ctx do + sock = connect(ctx.port) + send_line(sock, "NICK alice") + send_line(sock, "USER alice 0 * :Alice in Tests") + + lines = recv_until(sock, "005", 2000) + assert Enum.any?(lines, &String.contains?(&1, "001 alice :Welcome")) + assert Enum.any?(lines, &String.contains?(&1, "002 alice")) + assert Enum.any?(lines, &String.contains?(&1, "003 alice")) + assert Enum.any?(lines, &String.contains?(&1, "004 alice")) + assert Enum.any?(lines, &String.contains?(&1, "005 alice")) + + :gen_tcp.close(sock) + end + + test "PING/PONG works pre-registration", ctx do + sock = connect(ctx.port) + send_line(sock, "PING :probe") + [line] = recv_lines(sock, 1, 1000) + # Minimal PONG shape: server prefix + token in trailing. + # No middle params — some clients (ERC) match on trailing only. + assert line =~ ~r/^:test\.irc\.local PONG :probe/ + + :gen_tcp.close(sock) + end + + test "client PONG response clears server's outstanding-PING token", ctx do + # Server-side keepalive: every @ping_interval the server sends a + # PING with a unique token; if the next tick fires while the + # token is still outstanding, the connection is closed. Sending + # PONG must clear that token so the next tick re-arms cleanly. + # We drive the dispatcher synchronously here — sending a fake + # PONG out of nowhere is fine; it just hits the unsolicited path + # in the logger. + sock = connect(ctx.port) + register(sock, "pongtest") + + # Send a PONG (as if responding to a server PING) — should be + # accepted and clear the flag without any reply. + send_line(sock, "PONG :test.irc.local") + + # Connection should still be alive after handling PONG. + send_line(sock, "PING :alive-check") + [line] = recv_lines(sock, 1, 1000) + assert line =~ "PONG" + + :gen_tcp.close(sock) + end + + test "duplicate NICK collides with 433 ERR_NICKNAMEINUSE", ctx do + sock1 = connect(ctx.port) + register(sock1, "carol") + + sock2 = connect(ctx.port) + send_line(sock2, "NICK carol") + send_line(sock2, "USER carol 0 * :Carol") + + lines = recv_lines(sock2, 1, 1000) + assert Enum.any?(lines, &String.contains?(&1, "433")) + assert Enum.any?(lines, &String.contains?(&1, "carol")) + + :gen_tcp.close(sock1) + :gen_tcp.close(sock2) + end + end + + describe "channel ops" do + test "JOIN auto-creates room, echoes JOIN, sends NAMES", ctx do + room_id = "irc-test-#{:erlang.unique_integer([:positive])}" + sock = connect(ctx.port) + register(sock, "bob") + + send_line(sock, "JOIN ##{room_id}") + + lines = recv_until(sock, "366", 2000) + + assert Enum.any?(lines, fn l -> + l =~ ~r/^:bob![^ ]+ JOIN ##{room_id}/ + end), + "expected JOIN echo, got: #{inspect(lines)}" + + assert Enum.any?(lines, &String.contains?(&1, "353 bob = ##{room_id}")) + assert Enum.any?(lines, &String.contains?(&1, "366 bob ##{room_id}")) + + assert Room.exists?(room_id), "room should have been auto-created" + + :gen_tcp.close(sock) + Room.stop(room_id) + end + + test "PRIVMSG to channel reaches the Room transcript", ctx do + room_id = "irc-msg-#{:erlang.unique_integer([:positive])}" + sock = connect(ctx.port) + register(sock, "dave") + + send_line(sock, "JOIN ##{room_id}") + _ = recv_until(sock, "366", 2000) + + Phoenix.PubSub.subscribe(Egghead.PubSub, Room.topic(room_id)) + + send_line(sock, "PRIVMSG ##{room_id} :hello room") + + assert_receive {:user_message, msg}, 2000 + assert msg.content == "hello room" + assert msg.sender.type == :user + + :gen_tcp.close(sock) + Room.stop(room_id) + end + + test "QUIT closes the connection cleanly", ctx do + sock = connect(ctx.port) + register(sock, "eve") + send_line(sock, "QUIT :bye") + + # Server should close the socket; reading should hit :closed. + assert {:error, :closed} = read_until_closed(sock, 1000) + end + + test "PRIVMSG to channel is NOT echoed back to sender", ctx do + # Sender's nick must match $USER for this to suppress (see + # `own_user_message?/2` in Connection — single-user caveat: + # without per-conn user identity in transcripts, the only + # reliable "is this mine" signal is name-vs-nick). + nick = System.get_env("USER") || "user" + room_id = "no-echo-#{:erlang.unique_integer([:positive])}" + + sock = connect(ctx.port) + register(sock, nick) + send_line(sock, "JOIN ##{room_id}") + _ = recv_until(sock, "366", 2000) + + send_line(sock, "PRIVMSG ##{room_id} :hello echo") + + # No PRIVMSG should come back. Wait a beat to give PubSub time + # to propagate, then assert no PRIVMSG line is sitting on the + # socket. + :timer.sleep(150) + + assert {:error, :timeout} = :gen_tcp.recv(sock, 0, 100), + "expected no echo, but got something" + + :gen_tcp.close(sock) + Room.stop(room_id) + end + end + + describe "MODE" do + test "MODE #channel returns 324 channel mode is", ctx do + room_id = "mode-#{:erlang.unique_integer([:positive])}" + sock = connect(ctx.port) + register(sock, "alice") + send_line(sock, "JOIN ##{room_id}") + _ = recv_until(sock, "366", 2000) + + send_line(sock, "MODE ##{room_id}") + + lines = recv_lines(sock, 2, 1000) + assert Enum.any?(lines, &String.contains?(&1, "324 alice ##{room_id} +")) + assert Enum.any?(lines, &String.contains?(&1, "329 alice ##{room_id}")) + + :gen_tcp.close(sock) + Room.stop(room_id) + end + + test "MODE nick returns 221 user mode", ctx do + sock = connect(ctx.port) + register(sock, "modetest") + + send_line(sock, "MODE modetest") + [line] = recv_lines(sock, 1, 1000) + assert line =~ "221 modetest +" + + :gen_tcp.close(sock) + end + end + + describe "LIST" do + test "LIST with an empty trailing param (ERC's `/list`) returns all rooms", ctx do + # Regression: ERC and some other clients send `LIST :` (LIST with + # a `:`-introduced empty trailing param) when the user types + # /list with no filter. The parser puts that in `trailing` as "" + # and `Message.args/1` returns `[""]` — not `[]`. Earlier the + # handler treated `[""]` as a filter set and matched zero rooms. + room_id = "list-empty-trail-#{:erlang.unique_integer([:positive])}" + {:ok, _} = Room.start_link(id: room_id) + + sock = connect(ctx.port) + register(sock, "ercer") + + # Note the `:` — that's the empty trailing param ERC actually sends. + send_line(sock, "LIST :") + + lines = recv_until(sock, " 323 ercer", 2000) + + assert Enum.any?(lines, &String.contains?(&1, " 322 ercer ##{room_id}")), + "LIST : should match all rooms (got: #{inspect(lines)})" + + :gen_tcp.close(sock) + Room.stop(room_id) + end + + test "LIST returns all running rooms", ctx do + # Suffix is a fixed string (not the unique-integer counter) so the + # room id can never accidentally contain a numeric like "323" that + # collides with the LIST-end marker we recv_until on. + room_id = "list-room-#{:erlang.unique_integer([:positive])}" + {:ok, _} = Room.start_link(id: room_id) + + sock = connect(ctx.port) + register(sock, "lister") + + send_line(sock, "LIST") + + lines = recv_until(sock, " 323 lister", 2000) + assert Enum.any?(lines, &String.contains?(&1, " 321 lister")) + assert Enum.any?(lines, &String.contains?(&1, " 322 lister ##{room_id}")) + assert Enum.any?(lines, &String.contains?(&1, " 323 lister")) + + :gen_tcp.close(sock) + Room.stop(room_id) + end + + test "LIST marks the default room with a topic hint", ctx do + room_id = "list-default-#{:erlang.unique_integer([:positive])}" + {:ok, _} = Room.start_link(id: room_id) + + saved = :persistent_term.get(:egghead_default_room, nil) + :persistent_term.put(:egghead_default_room, room_id) + + on_exit(fn -> + if saved, + do: :persistent_term.put(:egghead_default_room, saved), + else: :persistent_term.erase(:egghead_default_room) + end) + + sock = connect(ctx.port) + register(sock, "default-lister") + + send_line(sock, "LIST") + lines = recv_until(sock, " 323 default-lister", 2000) + + entry = + Enum.find(lines, &String.contains?(&1, " 322 default-lister ##{room_id}")) + + assert entry, "expected 322 RPL_LIST entry for #{room_id}" + assert entry =~ "Default room" + assert entry =~ "#default" + + :gen_tcp.close(sock) + Room.stop(room_id) + end + end + + describe "#default alias" do + setup do + room_id = "alias-default-#{:erlang.unique_integer([:positive])}" + {:ok, _} = Room.start_link(id: room_id) + + saved = :persistent_term.get(:egghead_default_room, nil) + :persistent_term.put(:egghead_default_room, room_id) + + on_exit(fn -> + if saved, + do: :persistent_term.put(:egghead_default_room, saved), + else: :persistent_term.erase(:egghead_default_room) + + if Room.exists?(room_id), do: Room.stop(room_id) + end) + + {:ok, room_id: room_id} + end + + test "JOIN #default echoes the alias name (so strict clients open the right buffer)", ctx do + sock = connect(ctx.port) + register(sock, "aliaser") + + send_line(sock, "JOIN #default") + lines = recv_until(sock, "366 aliaser", 2000) + + # Strict clients (ERC) only open a channel buffer when the JOIN + # echo references the channel they asked for. Echoing the + # canonical name silently fails — the buffer is never created. + assert Enum.any?(lines, fn l -> + l =~ ~r/^:aliaser![^ ]+ JOIN #default/ + end), + "JOIN echo must use #default (the typed name), not canonical. got: #{inspect(lines)}" + + assert Enum.any?(lines, &String.contains?(&1, "353 aliaser = #default")), + "NAMES reply must reference #default too" + + :gen_tcp.close(sock) + end + + test "PRIVMSG #default routes to the canonical room", ctx do + sock = connect(ctx.port) + register(sock, "talker") + + send_line(sock, "JOIN #default") + _ = recv_until(sock, "366 talker", 2000) + + Phoenix.PubSub.subscribe(Egghead.PubSub, Room.topic(ctx.room_id)) + + send_line(sock, "PRIVMSG #default :hello via alias") + + assert_receive {:user_message, msg}, 2000 + assert msg.content == "hello via alias" + assert msg.room_id == ctx.room_id + + :gen_tcp.close(sock) + end + + test "agent events on the canonical room arrive as #default", ctx do + sock = connect(ctx.port) + register(sock, "watcher") + + send_line(sock, "JOIN #default") + _ = recv_until(sock, "366 watcher", 2000) + + Phoenix.PubSub.broadcast( + Egghead.PubSub, + Room.topic(ctx.room_id), + {:agent_passed, "agents/scout"} + ) + + {:ok, line} = :gen_tcp.recv(sock, 0, 2000) + assert String.trim_trailing(line, "\r\n") =~ "PRIVMSG #default :" + + :gen_tcp.close(sock) + end + + test "PART #default echoes #default and tears down the alias", ctx do + sock = connect(ctx.port) + register(sock, "leaver") + + send_line(sock, "JOIN #default") + _ = recv_until(sock, "366 leaver", 2000) + + send_line(sock, "PART #default") + {:ok, line} = :gen_tcp.recv(sock, 0, 2000) + assert String.trim_trailing(line, "\r\n") =~ ~r/^:leaver![^ ]+ PART #default/ + + :gen_tcp.close(sock) + end + end + + # --- helpers --- + + defp connect(port) do + {:ok, sock} = :gen_tcp.connect(~c"127.0.0.1", port, [:binary, active: false, packet: :line]) + sock + end + + defp send_line(sock, line) do + :ok = :gen_tcp.send(sock, [line, "\r\n"]) + end + + defp register(sock, nick) do + send_line(sock, "NICK #{nick}") + send_line(sock, "USER #{nick} 0 * :#{nick}") + _ = recv_until(sock, "005", 2000) + :ok + end + + defp recv_lines(sock, n, timeout) do + Enum.map(1..n, fn _ -> + {:ok, line} = :gen_tcp.recv(sock, 0, timeout) + String.trim_trailing(line, "\r\n") + end) + end + + # Read lines until one of them contains the given numeric, then return + # the accumulated list (including the matching line). Stops on timeout. + defp recv_until(sock, marker, timeout) do + deadline = System.monotonic_time(:millisecond) + timeout + do_recv_until(sock, marker, deadline, []) + end + + defp do_recv_until(sock, marker, deadline, acc) do + remaining = max(deadline - System.monotonic_time(:millisecond), 1) + + case :gen_tcp.recv(sock, 0, remaining) do + {:ok, data} -> + line = String.trim_trailing(data, "\r\n") + acc = [line | acc] + + if line =~ marker do + Enum.reverse(acc) + else + do_recv_until(sock, marker, deadline, acc) + end + + {:error, _} -> + Enum.reverse(acc) + end + end + + defp read_until_closed(sock, timeout) do + case :gen_tcp.recv(sock, 0, timeout) do + {:ok, _} -> read_until_closed(sock, timeout) + {:error, :closed} -> {:error, :closed} + {:error, _} = err -> err + end + end + + defp free_port do + {:ok, l} = :gen_tcp.listen(0, [:binary, active: false]) + {:ok, port} = :inet.port(l) + :gen_tcp.close(l) + port + end +end diff --git a/test/egghead/irc/server_time_test.exs b/test/egghead/irc/server_time_test.exs new file mode 100644 index 0000000..09fb971 --- /dev/null +++ b/test/egghead/irc/server_time_test.exs @@ -0,0 +1,286 @@ +defmodule Egghead.IRC.ServerTimeTest do + @moduledoc """ + IRCv3 `server-time` capability and history replay on JOIN. + + When a client negotiates `server-time`, every outbound chat-shaped + line carries an `@time=ISO-8601` tag, and JOIN replays the last N + transcript messages tagged with their original timestamps so the + IRC client can slot them into scrollback at the right historical + moment instead of at "now." + """ + + use ExUnit.Case + + alias Egghead.Chat.Room + alias Egghead.Chat.Room.{Sender, Message} + + setup_all do + case Phoenix.PubSub.Supervisor.start_link(name: Egghead.PubSub) do + {:ok, _} -> :ok + {:error, {:already_started, _}} -> :ok + end + + :ok + end + + setup do + port = free_port() + + opts = [ + config: %{ + port: port, + bind: "127.0.0.1", + hostname: "test.irc.local", + password: nil + } + ] + + start_supervised!({Egghead.IRC.Server, opts}) + + {:ok, port: port} + end + + describe "CAP negotiation" do + test "CAP LS advertises server-time", %{port: port} do + sock = connect(port) + send_line(sock, "CAP LS 302") + + line = recv_one(sock, 1500) + assert line =~ "CAP * LS :" + assert line =~ "server-time" + end + + test "CAP REQ server-time is ACKed", %{port: port} do + sock = connect(port) + send_line(sock, "CAP LS 302") + _ls = recv_one(sock, 1500) + + send_line(sock, "CAP REQ :server-time") + ack = recv_one(sock, 1500) + assert ack =~ "CAP * ACK :server-time" + end + + test "CAP REQ for an unsupported cap is NAKed", %{port: port} do + sock = connect(port) + send_line(sock, "CAP LS 302") + _ls = recv_one(sock, 1500) + + send_line(sock, "CAP REQ :nonexistent-cap") + nak = recv_one(sock, 1500) + assert nak =~ "CAP * NAK :nonexistent-cap" + end + + test "CAP END after ACK + NICK + USER completes registration", %{port: port} do + sock = connect(port) + send_line(sock, "CAP LS 302") + _ = recv_one(sock, 1500) + send_line(sock, "CAP REQ :server-time") + _ = recv_one(sock, 1500) + send_line(sock, "CAP END") + + send_line(sock, "NICK capclient") + send_line(sock, "USER capclient 0 * :Cap Client") + + lines = recv_until(sock, " 005 capclient", 2000) + assert Enum.any?(lines, &String.contains?(&1, " 001 capclient :Welcome")) + end + end + + describe "@time tag on outbound messages" do + test "PRIVMSG to a channel carries @time= when server-time is enabled", %{port: port} do + room_id = "stime-#{:erlang.unique_integer([:positive])}" + {:ok, _} = Room.start_link(id: room_id) + on_exit(fn -> if Room.exists?(room_id), do: Room.stop(room_id) end) + + sock = open_with_server_time(port, "tagger") + send_line(sock, "JOIN ##{room_id}") + _ = recv_until(sock, "366 tagger", 2000) + + # Push a message INTO the room from a different sender so it + # comes back to us as an outbound PRIVMSG (own-message echo + # is suppressed). + msg = %Message{ + id: "m", + room_id: room_id, + sender: %Sender{type: :agent, id: "agents/scout", name: "Scout"}, + content: "hello with timestamp", + timestamp: DateTime.utc_now() + } + + Phoenix.PubSub.broadcast(Egghead.PubSub, Room.topic(room_id), {:agent_message, msg}) + + line = recv_one(sock, 1500) + assert line =~ ~r/^@time=20\d\d-\d\d-\d\dT/ + assert line =~ "PRIVMSG ##{room_id}" + + :gen_tcp.close(sock) + end + + test "no @time tag when client did NOT negotiate server-time", %{port: port} do + room_id = "notime-#{:erlang.unique_integer([:positive])}" + {:ok, _} = Room.start_link(id: room_id) + on_exit(fn -> if Room.exists?(room_id), do: Room.stop(room_id) end) + + sock = open_plain(port, "untagger") + send_line(sock, "JOIN ##{room_id}") + _ = recv_until(sock, "366 untagger", 2000) + + msg = %Message{ + id: "m", + room_id: room_id, + sender: %Sender{type: :agent, id: "agents/scout", name: "Scout"}, + content: "hello no tag", + timestamp: DateTime.utc_now() + } + + Phoenix.PubSub.broadcast(Egghead.PubSub, Room.topic(room_id), {:agent_message, msg}) + + line = recv_one(sock, 1500) + refute line =~ "@time" + assert line =~ "PRIVMSG ##{room_id}" + + :gen_tcp.close(sock) + end + end + + describe "scrollback replay on JOIN" do + test "JOIN with server-time replays transcript with original timestamps", %{port: port} do + room_id = "scroll-#{:erlang.unique_integer([:positive])}" + {:ok, _} = Room.start_link(id: room_id) + on_exit(fn -> if Room.exists?(room_id), do: Room.stop(room_id) end) + + # Seed the transcript with a couple of messages from days ago. + old_ts = DateTime.add(DateTime.utc_now(), -86_400, :second) + + :ok = + Room.send_message(room_id, "first historical line") + + Room.agent_respond(room_id, "agents/scout", "second historical line") + + _ = old_ts + + sock = open_with_server_time(port, "replayer") + send_line(sock, "JOIN ##{room_id}") + + # Drain through the JOIN burst (echo + topic + names) and + # collect everything until idle. Replay PRIVMSGs follow. + :timer.sleep(150) + lines = drain_all(sock, 800) + + timed_lines = + Enum.filter(lines, fn l -> String.starts_with?(l, "@time=") end) + + assert length(timed_lines) >= 2, + "expected at least two @time-tagged scrollback lines (got: #{inspect(lines)})" + + assert Enum.any?(timed_lines, &String.contains?(&1, "first historical line")) + assert Enum.any?(timed_lines, &String.contains?(&1, "second historical line")) + + :gen_tcp.close(sock) + end + + test "JOIN without server-time does NOT replay scrollback", %{port: port} do + # No server-time → no auto-replay. Replaying with current + # timestamps would be actively misleading for historical content + # (looks like a flood of fresh messages from minutes ago). Such + # clients can use the CHATHISTORY verb if they negotiated that + # cap, or live without history. + room_id = "noscroll-#{:erlang.unique_integer([:positive])}" + {:ok, _} = Room.start_link(id: room_id) + on_exit(fn -> if Room.exists?(room_id), do: Room.stop(room_id) end) + + Room.send_message(room_id, "would-be replayed") + + sock = open_plain(port, "noreplay") + send_line(sock, "JOIN ##{room_id}") + + :timer.sleep(150) + lines = drain_all(sock, 500) + + refute Enum.any?(lines, &String.contains?(&1, "would-be replayed")), + "scrollback must not appear when server-time wasn't negotiated" + + :gen_tcp.close(sock) + end + end + + # --- helpers --- + + defp open_with_server_time(port, nick) do + sock = connect(port) + send_line(sock, "CAP LS 302") + _ = recv_one(sock, 1500) + send_line(sock, "CAP REQ :server-time") + _ = recv_one(sock, 1500) + send_line(sock, "CAP END") + send_line(sock, "NICK #{nick}") + send_line(sock, "USER #{nick} 0 * :#{nick}") + _ = recv_until(sock, " 005 #{nick}", 2000) + sock + end + + defp open_plain(port, nick) do + sock = connect(port) + send_line(sock, "NICK #{nick}") + send_line(sock, "USER #{nick} 0 * :#{nick}") + _ = recv_until(sock, " 005 #{nick}", 2000) + sock + end + + defp connect(port) do + {:ok, sock} = :gen_tcp.connect(~c"127.0.0.1", port, [:binary, active: false, packet: :line]) + sock + end + + defp send_line(sock, line) do + :ok = :gen_tcp.send(sock, [line, "\r\n"]) + end + + defp recv_one(sock, timeout) do + {:ok, line} = :gen_tcp.recv(sock, 0, timeout) + String.trim_trailing(line, "\r\n") + end + + defp recv_until(sock, marker, timeout) do + deadline = System.monotonic_time(:millisecond) + timeout + do_recv_until(sock, marker, deadline, []) + end + + defp do_recv_until(sock, marker, deadline, acc) do + remaining = max(deadline - System.monotonic_time(:millisecond), 1) + + case :gen_tcp.recv(sock, 0, remaining) do + {:ok, data} -> + line = String.trim_trailing(data, "\r\n") + acc = [line | acc] + if line =~ marker, do: Enum.reverse(acc), else: do_recv_until(sock, marker, deadline, acc) + + {:error, _} -> + Enum.reverse(acc) + end + end + + defp drain_all(sock, timeout) do + deadline = System.monotonic_time(:millisecond) + timeout + do_drain(sock, deadline, []) + end + + defp do_drain(sock, deadline, acc) do + remaining = max(deadline - System.monotonic_time(:millisecond), 1) + + case :gen_tcp.recv(sock, 0, remaining) do + {:ok, data} -> + do_drain(sock, deadline, [String.trim_trailing(data, "\r\n") | acc]) + + {:error, _} -> + Enum.reverse(acc) + end + end + + defp free_port do + {:ok, l} = :gen_tcp.listen(0, [:binary, active: false]) + {:ok, port} = :inet.port(l) + :gen_tcp.close(l) + port + end +end diff --git a/test/egghead/irc/slash_verbs_test.exs b/test/egghead/irc/slash_verbs_test.exs new file mode 100644 index 0000000..52aacd8 --- /dev/null +++ b/test/egghead/irc/slash_verbs_test.exs @@ -0,0 +1,231 @@ +defmodule Egghead.IRC.SlashVerbsTest do + @moduledoc """ + Egghead's slash-command palette exposed as native IRC verbs (so + ERC's `/save`, `/handoff`, `/mute` etc. just work), plus the + synthesized channel topic and the `/context` snapshot command. + + Most verbs accept either an explicit `#channel` first argument or + default to the user's only joined channel. Both paths are covered. + """ + + use ExUnit.Case + + alias Egghead.Chat.Room + + setup_all do + case Phoenix.PubSub.Supervisor.start_link(name: Egghead.PubSub) do + {:ok, _} -> :ok + {:error, {:already_started, _}} -> :ok + end + + :ok + end + + setup do + port = free_port() + + opts = [ + config: %{ + port: port, + bind: "127.0.0.1", + hostname: "test.irc.local", + password: nil + } + ] + + start_supervised!({Egghead.IRC.Server, opts}) + + room_id = "m3-#{:erlang.unique_integer([:positive])}" + {:ok, _} = Room.start_link(id: room_id) + on_exit(fn -> if Room.exists?(room_id), do: Room.stop(room_id) end) + + sock = connect(port) + register(sock, "verbsy") + send_line(sock, "JOIN ##{room_id}") + _ = recv_until(sock, "366", 2000) + + {:ok, sock: sock, room_id: room_id, port: port} + end + + describe "TOPIC on JOIN" do + test "JOIN gets a 332 RPL_TOPIC and 333 RPL_TOPICWHOTIME", %{room_id: room_id, port: port} do + sock = connect(port) + register(sock, "topicwatcher") + send_line(sock, "JOIN ##{room_id}") + + lines = recv_until(sock, "366 topicwatcher", 2000) + assert Enum.any?(lines, &String.contains?(&1, "332 topicwatcher ##{room_id}")) + assert Enum.any?(lines, &String.contains?(&1, "333 topicwatcher ##{room_id}")) + # Topic body mentions agents (count is 0 in this empty room) + assert Enum.any?(lines, &String.contains?(&1, "agent")) + + :gen_tcp.close(sock) + end + + test "agent_joined broadcasts a TOPIC update", %{sock: sock, room_id: room_id} do + Phoenix.PubSub.broadcast( + Egghead.PubSub, + Room.topic(room_id), + {:agent_joined, "agents/scout"} + ) + + # JOIN line first, then TOPIC update. + first = recv_one(sock, 1500) + assert first =~ ~r/JOIN ##{room_id}/ + + second = recv_one(sock, 1500) + assert second =~ ~r/TOPIC ##{room_id} :/ + end + end + + describe "SAVE" do + test "with no args, SAVE replies with a NOTICE about the result", %{sock: sock} do + send_line(sock, "SAVE") + + line = recv_one(sock, 2000) + # The RecordStore isn't running in this test, so save fails — but + # the important thing is the verb dispatched to the right room + # and we got a NOTICE response (success or failure shape both + # start with the server NOTICE prefix). + assert line =~ ~r/^:test\.irc\.local NOTICE verbsy :Save/ + end + end + + describe "CONTINUE / HALT" do + test "CONTINUE in the only joined channel triggers Room.continue", %{ + sock: sock, + room_id: room_id + } do + Phoenix.PubSub.subscribe(Egghead.PubSub, Room.topic(room_id)) + + send_line(sock, "CONTINUE") + + assert_receive {:continued, _opts}, 2000 + end + + test "HALT broadcasts :halted", %{sock: sock, room_id: room_id} do + Phoenix.PubSub.subscribe(Egghead.PubSub, Room.topic(room_id)) + + send_line(sock, "HALT") + + assert_receive {:halted, ^room_id}, 2000 + end + end + + describe "MUTE / UNMUTE" do + test "MUTE targets the room's agent and broadcasts :muted_changed", %{ + sock: sock, + room_id: room_id + } do + :ok = Room.join(room_id, "agents/scout") + + Phoenix.PubSub.subscribe(Egghead.PubSub, Room.topic(room_id)) + send_line(sock, "MUTE scout") + + assert_receive {:muted_changed, "agents/scout", true}, 2000 + end + + test "UNMUTE reverses the mute", %{sock: sock, room_id: room_id} do + :ok = Room.join(room_id, "agents/scout") + :ok = Room.mute(room_id, "agents/scout") + + Phoenix.PubSub.subscribe(Egghead.PubSub, Room.topic(room_id)) + send_line(sock, "UNMUTE scout") + + assert_receive {:muted_changed, "agents/scout", false}, 2000 + end + + test "MUTE with unknown nick returns 401 ERR_NOSUCHNICK", %{sock: sock} do + send_line(sock, "MUTE nobody-here") + line = recv_one(sock, 1500) + assert line =~ "401 verbsy nobody-here" + end + end + + describe "channel inference" do + test "verb with no #channel arg in multiple channels yields a NOTICE asking for one", %{ + sock: sock, + port: port + } do + other_id = "m3-other-#{:erlang.unique_integer([:positive])}" + {:ok, _} = Room.start_link(id: other_id) + on_exit(fn -> if Room.exists?(other_id), do: Room.stop(other_id) end) + + send_line(sock, "JOIN ##{other_id}") + _ = recv_until(sock, "366", 2000) + + send_line(sock, "HALT") + + line = recv_one(sock, 1500) + assert line =~ "NOTICE verbsy :You're in multiple channels" + + _ = port + end + + test "explicit #channel arg overrides single-channel default", %{ + sock: sock, + room_id: room_id + } do + Phoenix.PubSub.subscribe(Egghead.PubSub, Room.topic(room_id)) + send_line(sock, "HALT ##{room_id}") + assert_receive {:halted, ^room_id}, 2000 + end + end + + describe "CONTEXT" do + test "CONTEXT in an empty room reports no agents", %{sock: sock} do + send_line(sock, "CONTEXT") + line = recv_one(sock, 1500) + assert line =~ "NOTICE verbsy :No agents in this room" + end + end + + # --- helpers --- + + defp connect(port) do + {:ok, sock} = :gen_tcp.connect(~c"127.0.0.1", port, [:binary, active: false, packet: :line]) + sock + end + + defp send_line(sock, line) do + :ok = :gen_tcp.send(sock, [line, "\r\n"]) + end + + defp register(sock, nick) do + send_line(sock, "NICK #{nick}") + send_line(sock, "USER #{nick} 0 * :#{nick}") + _ = recv_until(sock, "005", 2000) + :ok + end + + defp recv_one(sock, timeout) do + {:ok, line} = :gen_tcp.recv(sock, 0, timeout) + String.trim_trailing(line, "\r\n") + end + + defp recv_until(sock, marker, timeout) do + deadline = System.monotonic_time(:millisecond) + timeout + do_recv_until(sock, marker, deadline, []) + end + + defp do_recv_until(sock, marker, deadline, acc) do + remaining = max(deadline - System.monotonic_time(:millisecond), 1) + + case :gen_tcp.recv(sock, 0, remaining) do + {:ok, data} -> + line = String.trim_trailing(data, "\r\n") + acc = [line | acc] + if line =~ marker, do: Enum.reverse(acc), else: do_recv_until(sock, marker, deadline, acc) + + {:error, _} -> + Enum.reverse(acc) + end + end + + defp free_port do + {:ok, l} = :gen_tcp.listen(0, [:binary, active: false]) + {:ok, port} = :inet.port(l) + :gen_tcp.close(l) + port + end +end diff --git a/test/egghead/node_test.exs b/test/egghead/node_test.exs index 65d800a..0717a7e 100644 --- a/test/egghead/node_test.exs +++ b/test/egghead/node_test.exs @@ -146,6 +146,13 @@ defmodule Egghead.NodeTest do describe "discover_server/0 precedence" do test "EGGHEAD_SERVER pointing at unreachable host returns :none within timeout" do + # Regression: this test used to flake on dev boxes that already had + # `egghead serve` running locally. The old `with` chain fell through + # from a failed env probe to the local epmd lookup, silently grabbing + # the running local node — wrong instance, wrong host, wrong answer. + # Now `EGGHEAD_SERVER` is treated as an absolute directive: if set, + # only that target is consulted. Unreachable → `:none`, never the + # local fallback. System.put_env("EGGHEAD_SERVER", "egghead-test-nonexistent.invalid") # The timeout in discover_from_epmd is 2s. Allow some slack. @@ -155,6 +162,15 @@ defmodule Egghead.NodeTest do assert time_us < 5_000_000, "discover_server hung for #{div(time_us, 1000)}ms" end + test "EGGHEAD_SERVER set wins over a locally-registered egghead_server" do + # Even if the dev box has `egghead serve` running (so epmd would find + # `egghead_server@localhost`), an explicit env directive must short- + # circuit the discovery — never silently swap to a different node. + System.put_env("EGGHEAD_SERVER", "egghead-test-also-nonexistent.invalid") + + assert ENode.discover_server() == :none + end + test "explicit server.node config wins over local epmd lookup" do Application.put_env(:egghead, :server, %{ node: "egghead_server@some.fqdn.lan",