From 16258722c7aa15df451067e1f40b977abbfd4401 Mon Sep 17 00:00:00 2001 From: user Date: Tue, 17 Mar 2026 09:05:16 -0700 Subject: [PATCH] fix: include hostmask in NAMES replies (RPL_NAMREPLY) --- README.md | 2 +- internal/handlers/api.go | 12 ++-- internal/handlers/api_test.go | 132 ++++++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 46a0888..a8fff03 100644 --- a/README.md +++ b/README.md @@ -1014,7 +1014,7 @@ the server to the client (never C2S) and use 3-digit string codes in the | `331` | RPL_NOTOPIC | Channel has no topic (on JOIN) | `{"command":"331","to":"alice","params":["#general"],"body":["No topic is set"]}` | | `332` | RPL_TOPIC | On JOIN or TOPIC query | `{"command":"332","to":"alice","params":["#general"],"body":["Welcome!"]}` | | `352` | RPL_WHOREPLY | In response to WHO | `{"command":"352","to":"alice","params":["#general","bobident","host.example.com","neoirc","bob","H"],"body":["0 bob"]}` | -| `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"353","to":"alice","params":["=","#general"],"body":["@op1 alice bob +voiced1"]}` | +| `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"353","to":"alice","params":["=","#general"],"body":["op1!op1@host1 alice!alice@host2 bob!bob@host3"]}` | | `366` | RPL_ENDOFNAMES | End of NAMES response | `{"command":"366","to":"alice","params":["#general"],"body":["End of /NAMES list"]}` | | `372` | RPL_MOTD | MOTD line | `{"command":"372","to":"alice","body":["Welcome to the server"]}` | | `375` | RPL_MOTDSTART | Start of MOTD | `{"command":"375","to":"alice","body":["- neoirc-server Message of the Day -"]}` | diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 2cd0dba..418cc0e 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -1474,16 +1474,16 @@ func (hdlr *Handlers) deliverNamesNumerics( ) if memErr == nil && len(members) > 0 { - nicks := make([]string, 0, len(members)) + entries := make([]string, 0, len(members)) for _, mem := range members { - nicks = append(nicks, mem.Nick) + entries = append(entries, mem.Hostmask()) } hdlr.enqueueNumeric( ctx, clientID, irc.RplNamReply, nick, []string{"=", channel}, - strings.Join(nicks, " "), + strings.Join(entries, " "), ) } @@ -2082,16 +2082,16 @@ func (hdlr *Handlers) handleNames( ctx, chID, ) if memErr == nil && len(members) > 0 { - nicks := make([]string, 0, len(members)) + entries := make([]string, 0, len(members)) for _, mem := range members { - nicks = append(nicks, mem.Nick) + entries = append(entries, mem.Hostmask()) } hdlr.enqueueNumeric( ctx, clientID, irc.RplNamReply, nick, []string{"=", channel}, - strings.Join(nicks, " "), + strings.Join(entries, " "), ) } diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index 38dccff..eb72197 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -2400,3 +2400,135 @@ func TestNickBroadcastToChannels(t *testing.T) { ) } } + +func TestNamesShowsHostmask(t *testing.T) { + tserver := newTestServer(t) + + queryToken, lastID := setupChannelWithIdentMember( + tserver, "namesmember", "nmident", + "namesquery", "#namestest", + ) + + // Issue an explicit NAMES command. + tserver.sendCommand(queryToken, map[string]any{ + commandKey: "NAMES", + toKey: "#namestest", + }) + + msgs, _ := tserver.pollMessages(queryToken, lastID) + + assertNamesHostmask( + t, msgs, "namesmember", "nmident", + ) +} + +func TestNamesOnJoinShowsHostmask(t *testing.T) { + tserver := newTestServer(t) + + // First user joins to populate the channel. + firstToken := tserver.createSessionWithUsername( + "joinmem", "jmident", + ) + + tserver.sendCommand(firstToken, map[string]any{ + commandKey: joinCmd, toKey: "#joinnamestest", + }) + + // Second user joins; the JOIN triggers + // deliverNamesNumerics which should include + // hostmask data. + joinerToken := tserver.createSession("joiner") + + tserver.sendCommand(joinerToken, map[string]any{ + commandKey: joinCmd, toKey: "#joinnamestest", + }) + + msgs, _ := tserver.pollMessages(joinerToken, 0) + + assertNamesHostmask( + t, msgs, "joinmem", "jmident", + ) +} + +// setupChannelWithIdentMember creates a member session +// with username, joins a channel, then creates a querier +// and joins the same channel. Returns the querier token +// and last message ID. +func setupChannelWithIdentMember( + tserver *testServer, + memberNick, memberUsername, + querierNick, channel string, +) (string, int64) { + tserver.t.Helper() + + memberToken := tserver.createSessionWithUsername( + memberNick, memberUsername, + ) + + tserver.sendCommand(memberToken, map[string]any{ + commandKey: joinCmd, toKey: channel, + }) + + queryToken := tserver.createSession(querierNick) + + tserver.sendCommand(queryToken, map[string]any{ + commandKey: joinCmd, toKey: channel, + }) + + _, lastID := tserver.pollMessages(queryToken, 0) + + return queryToken, lastID +} + +// assertNamesHostmask verifies that a RPL_NAMREPLY (353) +// message contains the expected nick with hostmask format +// (nick!user@host). +func assertNamesHostmask( + t *testing.T, + msgs []map[string]any, + targetNick, expectedUsername string, +) { + t.Helper() + + for _, msg := range msgs { + code, ok := msg["code"].(float64) + if !ok || int(code) != 353 { + continue + } + + raw, exists := msg["body"] + if !exists || raw == nil { + continue + } + + arr, isArr := raw.([]any) + if !isArr || len(arr) == 0 { + continue + } + + bodyStr, isStr := arr[0].(string) + if !isStr { + continue + } + + // Look for the target nick's hostmask entry. + expected := targetNick + "!" + + expectedUsername + "@" + + if !strings.Contains(bodyStr, expected) { + t.Fatalf( + "expected NAMES body to contain %q, "+ + "got %q", + expected, bodyStr, + ) + } + + return + } + + t.Fatalf( + "expected RPL_NAMREPLY (353) with hostmask "+ + "for %s, msgs: %v", + targetNick, msgs, + ) +}