From 48f1bb2ce9149a5815dfe05f1943621ec3c4f625 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 21 Oct 2026 04:17:05 +0000 Subject: [PATCH] Relay INVITE to IRC listener users as an INVITE line (closes #147) An invite for an IRC listener user arrived as a server NOTICE that did not name the inviter, so no IRC client treated it as an invite. The relay now sends it as an INVITE line from the inviter, with the channel taken from the message body, and sends nothing when the body names no channel. The IRC listener now queues the INVITE with the channel as its body, as the HTTP API already did, so an invite from either transport has the same shape. The README's IRC listener section says how an invite arrives. Model: opus-5-5 --- README.md | 4 ++- internal/ircserver/commands.go | 7 +++-- internal/ircserver/integration_test.go | 36 ++++++++++++++++++++++++++ internal/ircserver/relay.go | 14 +++++++--- internal/ircserver/relay_test.go | 32 +++++++++++++++++++++++ 5 files changed, 85 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 7a31347..600fd51 100644 --- a/README.md +++ b/README.md @@ -2719,7 +2719,9 @@ Each line of a multi-line `body` reaches an IRC listener user as its own is sent, so it cannot start a line of its own. A mode change made over the HTTP API reaches IRC listener members as an IRC `MODE` line with its value as a separate parameter, e.g. `MODE #general +k secretpass` or -`MODE #general +o bob`. +`MODE #general +o bob`. An `INVITE` from either transport reaches the invited +IRC listener user as an IRC `INVITE` line from the inviter, e.g. +`:alice!alice@* INVITE bob #general`. ### Docker Usage diff --git a/internal/ircserver/commands.go b/internal/ircserver/commands.go index 77d1eb3..359a2df 100644 --- a/internal/ircserver/commands.go +++ b/internal/ircserver/commands.go @@ -1261,10 +1261,9 @@ func (c *Conn) handleInvite( irc.RplInviting, targetNick, channel, ) - // Send INVITE notice to target via service fan-out. - body, _ := json.Marshal( //nolint:errchkjson - []string{"You have been invited to " + channel}, - ) + // Queue the INVITE to the target with the body the HTTP + // API uses. + body, _ := json.Marshal([]string{channel}) //nolint:errchkjson _, _, _ = c.svc.FanOut( //nolint:dogsled // fire-and-forget broadcast ctx, "INVITE", c.nick, targetNick, targetID, 0, diff --git a/internal/ircserver/integration_test.go b/internal/ircserver/integration_test.go index 2dde41f..db4b4f2 100644 --- a/internal/ircserver/integration_test.go +++ b/internal/ircserver/integration_test.go @@ -1373,6 +1373,42 @@ func TestIntegrationInviteUserOnChannel(t *testing.T) { ) } +// TestIntegrationInviteLine checks that an IRC listener user +// invited by an IRC listener user, and then over the HTTP +// API, gets an INVITE line from the inviter for the channel +// each time. +func TestIntegrationInviteLine(t *testing.T) { + t.Parallel() + + env := newTestEnv(t) + + alice := env.dial(t) + alice.register("alice") + alice.joinAndDrain("#inv") + + bob := env.dial(t) + bob.register("bob") + + alice.sendAndExpect("INVITE bob #inv", " 341 ") + + got := bob.readLine() + want := ":alice!alice@* INVITE bob #inv" + + if got != want { + t.Errorf("from the IRC listener: got %q, want %q", got, want) + } + + // The HTTP API queues an INVITE with the channel as its body. + env.queueMessage(t, "bob", "INVITE", "carol", "bob", []string{"#inv"}) + + got = bob.readLine() + want = ":carol!carol@* INVITE bob #inv" + + if got != want { + t.Errorf("over the HTTP API: got %q, want %q", got, want) + } +} + // TestIntegrationModeSecretList checks that LIST shows a +s // channel only to its members. func TestIntegrationModeSecretList(t *testing.T) { diff --git a/internal/ircserver/relay.go b/internal/ircserver/relay.go index bf88c2e..96125a0 100644 --- a/internal/ircserver/relay.go +++ b/internal/ircserver/relay.go @@ -358,16 +358,24 @@ func (c *Conn) deliverKickMsg( } } -// deliverInviteMsg sends an INVITE notification. +// deliverInviteMsg sends an INVITE line from the inviter +// for the channel named in text, or nothing when text names +// no channel. func (c *Conn) deliverInviteMsg( - _ *db.IRCMessage, + msg *db.IRCMessage, text string, ) { + if text == "" { + return + } + c.mu.Lock() nick := c.nick c.mu.Unlock() - c.sendFromServer("NOTICE", nick, text) + prefix := msg.From + "!" + msg.From + "@*" + + c.send(FormatMessage(prefix, "INVITE", nick, text)) } // deliverWallops sends a WALLOPS notification. diff --git a/internal/ircserver/relay_test.go b/internal/ircserver/relay_test.go index 2b6a89e..c0d4b22 100644 --- a/internal/ircserver/relay_test.go +++ b/internal/ircserver/relay_test.go @@ -211,6 +211,38 @@ func TestRelayModeParameters(t *testing.T) { } } +// TestRelayInviteWithoutChannel checks that an INVITE whose +// body names no channel sends the invited IRC listener user +// no INVITE line. +func TestRelayInviteWithoutChannel(t *testing.T) { + t.Parallel() + + env := newTestEnv(t) + + bob := env.dial(t) + bob.register("bob") + + // A nil body is stored as JSON null, which is not an array. + for _, body := range [][]string{nil, {}, {""}} { + env.queueMessage(t, "bob", "INVITE", "carol", "bob", body) + } + + // The relay sends a user's queue in order, so this arrives + // after anything sent for the INVITEs above. + env.queueMessage(t, "bob", "PRIVMSG", "carol", "bob", + []string{"the end"}) + + lines := bob.readUntil(func(l string) bool { + return strings.HasSuffix(l, " :the end") + }) + + for _, line := range lines { + if strings.Contains(line, " INVITE ") { + t.Errorf("got %q, want no INVITE line", line) + } + } +} + // TestRelayLineBreaks checks that a CR, LF or NUL in a stored // body line, key or mask never reaches an IRC listener user // as a line break, so no extra line arrives, and that a body