neiam
/oh-my-ex-oh-ircd
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
testing changes
38c4d7b · gmorell · 2026-06-06 18:11
Files changed
modified
lib/oh_my_ex_oh_ircd/irc/channel.ex
+38
−9
@@ -348,7 +348,7 @@ defmodule OhMyExOhIrcd.IRC.Channel do
| 348 | 348 | |
| 349 | 349 | Phoenix.PubSub.broadcast(@pubsub, MessageLog.channel_topic(state.name), { |
| 350 | 350 | :irc_presence, |
| 351 | − {:join, nick, state.name} | |
| 351 | + {:join, member_prefix(member), state.name} | |
| 352 | 352 | }) |
| 353 | 353 | |
| 354 | 354 | # Restore the access prefix on rejoin with a synthetic MODE line. |
@@ -357,7 +357,7 @@ defmodule OhMyExOhIrcd.IRC.Channel do
| 357 | 357 | |
| 358 | 358 | Phoenix.PubSub.broadcast(@pubsub, MessageLog.channel_topic(state.name), { |
| 359 | 359 | :irc_presence, |
| 360 | − {:mode, "ChanServ", state.name, "+" <> letter, [nick]} | |
| 360 | + {:mode, service_prefix(), state.name, "+" <> letter, [nick]} | |
| 361 | 361 | }) |
| 362 | 362 | end |
| 363 | 363 |
@@ -369,10 +369,11 @@ defmodule OhMyExOhIrcd.IRC.Channel do
| 369 | 369 | key = downcase(nick) |
| 370 | 370 | {info, members} = Map.pop(state.members, key) |
| 371 | 371 | if info, do: Process.demonitor(info.ref, [:flush]) |
| 372 | + sender = if info, do: member_prefix(info), else: "#{nick}!#{nick}@unknown" | |
| 372 | 373 | |
| 373 | 374 | Phoenix.PubSub.broadcast(@pubsub, MessageLog.channel_topic(state.name), { |
| 374 | 375 | :irc_presence, |
| 375 | − {:part, nick, state.name, reason} | |
| 376 | + {:part, sender, state.name, reason} | |
| 376 | 377 | }) |
| 377 | 378 | |
| 378 | 379 | {:reply, :ok, %{state | members: members}} |
@@ -488,7 +489,7 @@ defmodule OhMyExOhIrcd.IRC.Channel do
| 488 | 489 | |
| 489 | 490 | Phoenix.PubSub.broadcast(@pubsub, MessageLog.channel_topic(state.name), { |
| 490 | 491 | :irc_presence, |
| 491 | − {:topic, set_by, state.name, text} | |
| 492 | + {:topic, sender_prefix(state, set_by), state.name, text} | |
| 492 | 493 | }) |
| 493 | 494 | |
| 494 | 495 | {:reply, :ok, state} |
@@ -522,7 +523,7 @@ defmodule OhMyExOhIrcd.IRC.Channel do
| 522 | 523 | if flag_letter do |
| 523 | 524 | Phoenix.PubSub.broadcast(@pubsub, MessageLog.channel_topic(state.name), { |
| 524 | 525 | :irc_presence, |
| 525 | − {:mode, granter, state.name, sign <> flag_letter, [member.nick]} | |
| 526 | + {:mode, sender_prefix(state, granter), state.name, sign <> flag_letter, [member.nick]} | |
| 526 | 527 | }) |
| 527 | 528 | end |
| 528 | 529 |
@@ -535,7 +536,7 @@ defmodule OhMyExOhIrcd.IRC.Channel do
| 535 | 536 | nil -> |
| 536 | 537 | {:reply, {:error, :not_on_channel}, state} |
| 537 | 538 | |
| 538 | − %{access: kicker_access} -> | |
| 539 | + %{access: kicker_access} = kicker_member -> | |
| 539 | 540 | case Map.get(state.members, downcase(target)) do |
| 540 | 541 | nil -> |
| 541 | 542 | {:reply, {:error, :no_such_member}, state} |
@@ -554,7 +555,7 @@ defmodule OhMyExOhIrcd.IRC.Channel do
| 554 | 555 | |
| 555 | 556 | Phoenix.PubSub.broadcast(@pubsub, MessageLog.channel_topic(state.name), { |
| 556 | 557 | :irc_presence, |
| 557 | − {:kick, kicker, state.name, tnick, reason} | |
| 558 | + {:kick, member_prefix(kicker_member), state.name, tnick, reason} | |
| 558 | 559 | }) |
| 559 | 560 | |
| 560 | 561 | send(pid, {:irc_kicked, state.name}) |
@@ -792,7 +793,7 @@ defmodule OhMyExOhIrcd.IRC.Channel do
| 792 | 793 | |
| 793 | 794 | Phoenix.PubSub.broadcast(@pubsub, MessageLog.channel_topic(state.name), { |
| 794 | 795 | :irc_presence, |
| 795 | − {:mode, granter, state.name, flags, targets} | |
| 796 | + {:mode, sender_prefix(state, granter), state.name, flags, targets} | |
| 796 | 797 | }) |
| 797 | 798 | |
| 798 | 799 | # Persist if any of the applied changes were channel-level (not just |
@@ -856,7 +857,7 @@ defmodule OhMyExOhIrcd.IRC.Channel do
| 856 | 857 | if member do |
| 857 | 858 | Phoenix.PubSub.broadcast(@pubsub, MessageLog.channel_topic(state.name), { |
| 858 | 859 | :irc_presence, |
| 859 | − {:quit, member.nick, state.name, "Connection closed"} | |
| 860 | + {:quit, member_prefix(member), state.name, "Connection closed"} | |
| 860 | 861 | }) |
| 861 | 862 | |
| 862 | 863 | {:noreply, %{state | members: Map.delete(state.members, key)}} |
@@ -890,4 +891,32 @@ defmodule OhMyExOhIrcd.IRC.Channel do
| 890 | 891 | {nick, "unknown"} |
| 891 | 892 | end |
| 892 | 893 | end |
| 894 | + | |
| 895 | + # Full `nick!user@host` prefix for a member. Presence broadcasts | |
| 896 | + # (JOIN/PART/QUIT/KICK/…) must carry this full form — clients like | |
| 897 | + # irssi take the host from the message prefix to populate their | |
| 898 | + # nicklists and assert it's present (`nicklist_set_host: host != NULL`). | |
| 899 | + defp member_prefix(m), | |
| 900 | + do: "#{m.nick}!#{Map.get(m, :user) || m.nick}@#{Map.get(m, :host) || "unknown"}" | |
| 901 | + | |
| 902 | + # Same, but by nick lookup — for senders that may not be plain members | |
| 903 | + # (services, or a setter that raced a part). Falls back to a synthetic | |
| 904 | + # but well-formed prefix so the host token is never empty on the wire. | |
| 905 | + defp sender_prefix(state, nick) do | |
| 906 | + case Map.get(state.members, downcase(nick)) do | |
| 907 | + nil -> | |
| 908 | + if downcase(nick) == downcase(OhMyExOhIrcd.IRC.ChanServ.nick()) do | |
| 909 | + service_prefix() | |
| 910 | + else | |
| 911 | + "#{nick}!#{nick}@unknown" | |
| 912 | + end | |
| 913 | + | |
| 914 | + m -> | |
| 915 | + member_prefix(m) | |
| 916 | + end | |
| 917 | + end | |
| 918 | + | |
| 919 | + defp service_prefix do | |
| 920 | + "#{OhMyExOhIrcd.IRC.ChanServ.nick()}!service@#{OhMyExOhIrcd.IRC.ChanServ.host()}" | |
| 921 | + end | |
| 893 | 922 | end |
modified
lib/oh_my_ex_oh_ircd/irc/connection.ex
+19
−10
@@ -186,25 +186,28 @@ defmodule OhMyExOhIrcd.IRC.Connection do
| 186 | 186 | {:noreply, {socket, state}} |
| 187 | 187 | end |
| 188 | 188 | |
| 189 | − def handle_info({:irc_presence, {:join, nick, channel}}, {socket, state}) do | |
| 190 | − if MapSet.member?(state.channels, downcase(channel)) and nick != state.nick do | |
| 191 | − send_msg(socket, Message.build("JOIN", [channel], nick)) | |
| 189 | + # Presence senders arrive as the full `nick!user@host` prefix and are | |
| 190 | + # relayed verbatim — clients (irssi in particular) need the host part to | |
| 191 | + # maintain their nicklists. Self-comparisons strip the nick back out. | |
| 192 | + def handle_info({:irc_presence, {:join, sender, channel}}, {socket, state}) do | |
| 193 | + if MapSet.member?(state.channels, downcase(channel)) and prefix_nick(sender) != state.nick do | |
| 194 | + send_msg(socket, Message.build("JOIN", [channel], sender)) | |
| 192 | 195 | end |
| 193 | 196 | |
| 194 | 197 | {:noreply, {socket, state}} |
| 195 | 198 | end |
| 196 | 199 | |
| 197 | − def handle_info({:irc_presence, {:part, nick, channel, reason}}, {socket, state}) do | |
| 198 | − if MapSet.member?(state.channels, downcase(channel)) and nick != state.nick do | |
| 200 | + def handle_info({:irc_presence, {:part, sender, channel, reason}}, {socket, state}) do | |
| 201 | + if MapSet.member?(state.channels, downcase(channel)) and prefix_nick(sender) != state.nick do | |
| 199 | 202 | params = if reason == "", do: [channel], else: [channel, reason] |
| 200 | − send_msg(socket, Message.build("PART", params, nick)) | |
| 203 | + send_msg(socket, Message.build("PART", params, sender)) | |
| 201 | 204 | end |
| 202 | 205 | |
| 203 | 206 | {:noreply, {socket, state}} |
| 204 | 207 | end |
| 205 | 208 | |
| 206 | 209 | def handle_info({:irc_presence, {:topic, set_by, channel, text}}, {socket, state}) do |
| 207 | − if MapSet.member?(state.channels, downcase(channel)) and set_by != state.nick do | |
| 210 | + if MapSet.member?(state.channels, downcase(channel)) and prefix_nick(set_by) != state.nick do | |
| 208 | 211 | send_msg(socket, Message.build("TOPIC", [channel, text], set_by)) |
| 209 | 212 | end |
| 210 | 213 |
@@ -285,9 +288,9 @@ defmodule OhMyExOhIrcd.IRC.Connection do
| 285 | 288 | {:noreply, {socket, state}} |
| 286 | 289 | end |
| 287 | 290 | |
| 288 | − def handle_info({:irc_presence, {:quit, nick, channel, reason}}, {socket, state}) do | |
| 289 | − if MapSet.member?(state.channels, downcase(channel)) and nick != state.nick do | |
| 290 | − send_msg(socket, Message.build("QUIT", [reason], nick)) | |
| 291 | + def handle_info({:irc_presence, {:quit, sender, channel, reason}}, {socket, state}) do | |
| 292 | + if MapSet.member?(state.channels, downcase(channel)) and prefix_nick(sender) != state.nick do | |
| 293 | + send_msg(socket, Message.build("QUIT", [reason], sender)) | |
| 291 | 294 | end |
| 292 | 295 | |
| 293 | 296 | {:noreply, {socket, state}} |
@@ -1623,6 +1626,12 @@ defmodule OhMyExOhIrcd.IRC.Connection do
| 1623 | 1626 | end |
| 1624 | 1627 | end |
| 1625 | 1628 | |
| 1629 | + # Bare nick from a `nick!user@host` prefix (or a bare nick, unchanged). | |
| 1630 | + defp prefix_nick(prefix) when is_binary(prefix), | |
| 1631 | + do: prefix |> String.split("!", parts: 2) |> List.first() | |
| 1632 | + | |
| 1633 | + defp prefix_nick(_), do: nil | |
| 1634 | + | |
| 1626 | 1635 | defp owns_sender?(%State{nick: nil}, _sender), do: false |
| 1627 | 1636 | |
| 1628 | 1637 | defp owns_sender?(%State{nick: nick}, sender) when is_binary(sender) do |
modified
lib/oh_my_ex_oh_ircd_web/live/web_irc_live.ex
+17
−6
@@ -307,19 +307,28 @@ defmodule OhMyExOhIrcdWeb.WebIrcLive do
| 307 | 307 | push_line(socket, channel_name, line) |
| 308 | 308 | end |
| 309 | 309 | |
| 310 | − defp push_presence(socket, {:join, nick, channel}) do | |
| 310 | + # Presence senders arrive as full `nick!user@host` prefixes (built by | |
| 311 | + # Channel for the benefit of native IRC clients); strip to the bare | |
| 312 | + # nick for display and member-list bookkeeping. | |
| 313 | + defp push_presence(socket, {:join, sender, channel}) do | |
| 314 | + nick = prefix_nick(sender) | |
| 315 | + | |
| 311 | 316 | socket |
| 312 | 317 | |> push_line(channel, %{kind: :join, nick: nick}) |
| 313 | 318 | |> update_members(channel, fn members -> Enum.uniq([nick | members]) end) |
| 314 | 319 | end |
| 315 | 320 | |
| 316 | − defp push_presence(socket, {:part, nick, channel, reason}) do | |
| 321 | + defp push_presence(socket, {:part, sender, channel, reason}) do | |
| 322 | + nick = prefix_nick(sender) | |
| 323 | + | |
| 317 | 324 | socket |
| 318 | 325 | |> push_line(channel, %{kind: :part, nick: nick, reason: reason}) |
| 319 | 326 | |> update_members(channel, fn members -> Enum.reject(members, &same_nick?(&1, nick)) end) |
| 320 | 327 | end |
| 321 | 328 | |
| 322 | − defp push_presence(socket, {:quit, nick, channel, reason}) do | |
| 329 | + defp push_presence(socket, {:quit, sender, channel, reason}) do | |
| 330 | + nick = prefix_nick(sender) | |
| 331 | + | |
| 323 | 332 | socket |
| 324 | 333 | |> push_line(channel, %{kind: :quit, nick: nick, reason: reason}) |
| 325 | 334 | |> update_members(channel, fn members -> Enum.reject(members, &same_nick?(&1, nick)) end) |
@@ -332,7 +341,7 @@ defmodule OhMyExOhIrcdWeb.WebIrcLive do
| 332 | 341 | end |
| 333 | 342 | |
| 334 | 343 | defp push_presence(socket, {:nick_change, old_prefix, new_nick, channel}) do |
| 335 | − old_nick = old_prefix |> String.split("!", parts: 2) |> List.first() | |
| 344 | + old_nick = prefix_nick(old_prefix) | |
| 336 | 345 | |
| 337 | 346 | socket |
| 338 | 347 | |> push_line(channel, %{kind: :status, text: "#{old_nick} is now known as #{new_nick}"}) |
@@ -344,19 +353,21 @@ defmodule OhMyExOhIrcdWeb.WebIrcLive do
| 344 | 353 | end |
| 345 | 354 | |
| 346 | 355 | defp push_presence(socket, {:topic, set_by, channel, text}), |
| 347 | − do: push_line(socket, channel, %{kind: :topic, set_by: set_by, text: text}) | |
| 356 | + do: push_line(socket, channel, %{kind: :topic, set_by: prefix_nick(set_by), text: text}) | |
| 348 | 357 | |
| 349 | 358 | defp push_presence(socket, {:mode, granter, channel, flags, targets}), |
| 350 | 359 | do: |
| 351 | 360 | push_line(socket, channel, %{ |
| 352 | 361 | kind: :mode, |
| 353 | − granter: granter, | |
| 362 | + granter: prefix_nick(granter), | |
| 354 | 363 | flags: flags, |
| 355 | 364 | targets: targets |
| 356 | 365 | }) |
| 357 | 366 | |
| 358 | 367 | defp push_presence(socket, _), do: socket |
| 359 | 368 | |
| 369 | + defp prefix_nick(prefix), do: prefix |> String.split("!", parts: 2) |> List.first() | |
| 370 | + | |
| 360 | 371 | # The member list in `channels[name].members` is whatever we |
| 361 | 372 | # populated at JOIN — `push_presence/2` keeps it in sync as |
| 362 | 373 | # join/part/quit/kick/nick-change events arrive. |
modified
test/oh_my_ex_oh_ircd/irc/channel_ops_test.exs
+6
−4
@@ -108,12 +108,13 @@ defmodule OhMyExOhIrcd.IRC.ChannelOpsTest do
| 108 | 108 | a_lines = drain(a, 300) |
| 109 | 109 | b_lines = drain(b, 300) |
| 110 | 110 | |
| 111 | + # The relayed MODE carries the granter's full nick!user@host prefix. | |
| 111 | 112 | assert Enum.any?(a_lines, fn l -> |
| 112 | − l =~ ~r/^:#{a_nick} MODE #{Regex.escape(chan)} \+o #{b_nick}$/ | |
| 113 | + l =~ ~r/^:#{a_nick}!\S+@\S+ MODE #{Regex.escape(chan)} \+o #{b_nick}$/ | |
| 113 | 114 | end) |
| 114 | 115 | |
| 115 | 116 | assert Enum.any?(b_lines, fn l -> |
| 116 | − l =~ ~r/^:#{a_nick} MODE #{Regex.escape(chan)} \+o #{b_nick}$/ | |
| 117 | + l =~ ~r/^:#{a_nick}!\S+@\S+ MODE #{Regex.escape(chan)} \+o #{b_nick}$/ | |
| 117 | 118 | end) |
| 118 | 119 | end |
| 119 | 120 |
@@ -180,12 +181,13 @@ defmodule OhMyExOhIrcd.IRC.ChannelOpsTest do
| 180 | 181 | a_lines = drain(a, 300) |
| 181 | 182 | b_lines = drain(b, 300) |
| 182 | 183 | |
| 184 | + # The relayed KICK carries the kicker's full nick!user@host prefix. | |
| 183 | 185 | assert Enum.any?(a_lines, fn l -> |
| 184 | − l =~ ~r/^:#{a_nick} KICK #{Regex.escape(chan)} #{b_nick} :?bye$/ | |
| 186 | + l =~ ~r/^:#{a_nick}!\S+@\S+ KICK #{Regex.escape(chan)} #{b_nick} :?bye$/ | |
| 185 | 187 | end) |
| 186 | 188 | |
| 187 | 189 | assert Enum.any?(b_lines, fn l -> |
| 188 | − l =~ ~r/^:#{a_nick} KICK #{Regex.escape(chan)} #{b_nick} :?bye$/ | |
| 190 | + l =~ ~r/^:#{a_nick}!\S+@\S+ KICK #{Regex.escape(chan)} #{b_nick} :?bye$/ | |
| 189 | 191 | end) |
| 190 | 192 | |
| 191 | 193 | send_line(b, "PRIVMSG #{chan} :still here?") |
modified
test/oh_my_ex_oh_ircd/irc/connection_integration_test.exs
+33
−0
@@ -123,6 +123,39 @@ defmodule OhMyExOhIrcd.IRC.ConnectionIntegrationTest do
| 123 | 123 | end |
| 124 | 124 | end |
| 125 | 125 | |
| 126 | + describe "presence relays" do | |
| 127 | + # Relayed JOIN/PART/QUIT must carry the full `nick!user@host` prefix. | |
| 128 | + # A bare-nick prefix makes irssi insert a nicklist entry with a NULL | |
| 129 | + # host (`nicklist_set_host: assertion 'host != NULL' failed`). | |
| 130 | + test "relayed JOIN and QUIT carry the sender's full prefix" do | |
| 131 | + port = start_listener() | |
| 132 | + chan = unique_channel("presence") | |
| 133 | + a_nick = unique_nick("alice") | |
| 134 | + b_nick = unique_nick("bob") | |
| 135 | + | |
| 136 | + a = register(port, a_nick) | |
| 137 | + send_line(a, "JOIN #{chan}") | |
| 138 | + drain(a, 200) | |
| 139 | + | |
| 140 | + b = register(port, b_nick) | |
| 141 | + send_line(b, "JOIN #{chan}") | |
| 142 | + drain(b, 200) | |
| 143 | + | |
| 144 | + join_lines = drain(a, 300) | |
| 145 | + | |
| 146 | + assert Enum.any?(join_lines, fn l -> | |
| 147 | + l =~ ~r/^:#{b_nick}!\S+@\S+ JOIN #{Regex.escape(chan)}$/ | |
| 148 | + end) | |
| 149 | + | |
| 150 | + send_line(b, "QUIT :bye") | |
| 151 | + quit_lines = drain(a, 300) | |
| 152 | + | |
| 153 | + assert Enum.any?(quit_lines, fn l -> | |
| 154 | + l =~ ~r/^:#{b_nick}!\S+@\S+ (QUIT|PART)/ | |
| 155 | + end) | |
| 156 | + end | |
| 157 | + end | |
| 158 | + | |
| 126 | 159 | describe "TOPIC" do |
| 127 | 160 | test "set + read back with 332 and 333 (setter + timestamp)" do |
| 128 | 161 | port = start_listener() |
Parents: a0ebd53