From 3cbdac7bdaffb77a8fc1f224e6adb285e0f71df6 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 21 Oct 2026 06:14:42 +0200 Subject: [PATCH] Send an IRC listener user their own TOPIC, MODE and KICK once (closes #143) The service sends a TOPIC, MODE or KICK to every channel member, the issuer included, and the IRC listener also echoes it to the issuer's connection, so an IRC listener user got each of their own changes twice. The relay now drops one whose sender is the connection's own nick, as it already does for PRIVMSG, NOTICE, JOIN, PART, NICK and QUIT. The direct echo stays: it carries the real hostmask and arrives in order. The README MODE section no longer describes a second copy. Model: opus-5-5 --- README.md | 5 +-- internal/ircserver/relay.go | 27 +++++++++++++ internal/ircserver/relay_test.go | 67 ++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b94af90..7a31347 100644 --- a/README.md +++ b/README.md @@ -1019,9 +1019,8 @@ On the IRC listener, `MODE #channel [values...]` takes several letters in one string, e.g. `MODE #general +mt-i` or `MODE #general +ov alice bob`. It accepts `i`, `m`, `n`, `s`, `t`, `o` and `v` (with a nick) and `H` (with bits from 1 to 40 when setting), but not `b`, `k` or `l`. A letter whose value is -missing is skipped. The changes made are echoed to the operator's connection and -also sent to every channel member, the operator included, as one `MODE` line, -e.g. `+o-v alice bob`. +missing is skipped. The changes made are sent to every channel member as one +`MODE` line, e.g. `+o-v alice bob`; the operator gets that line once. **Channel errors:** diff --git a/internal/ircserver/relay.go b/internal/ircserver/relay.go index c568133..bf88c2e 100644 --- a/internal/ircserver/relay.go +++ b/internal/ircserver/relay.go @@ -304,6 +304,15 @@ func (c *Conn) deliverTopicChange( msg *db.IRCMessage, text string, ) { + c.mu.Lock() + nick := c.nick + c.mu.Unlock() + + // handleTopic already echoed our own topic change. + if msg.From == nick { + return + } + prefix := msg.From + "!" + msg.From + "@*" channel := msg.To @@ -315,6 +324,15 @@ func (c *Conn) deliverKickMsg( msg *db.IRCMessage, text string, ) { + c.mu.Lock() + nick := c.nick + c.mu.Unlock() + + // handleKick already echoed our own kick. + if msg.From == nick { + return + } + prefix := msg.From + "!" + msg.From + "@*" channel := msg.To @@ -377,6 +395,15 @@ func (c *Conn) deliverMode( return } + c.mu.Lock() + nick := c.nick + c.mu.Unlock() + + // applyChannelModes already echoed our own mode change. + if msg.From == nick { + return + } + prefix := msg.From + "!" + msg.From + "@*" params := slices.Concat( diff --git a/internal/ircserver/relay_test.go b/internal/ircserver/relay_test.go index 80d2ace..2b6a89e 100644 --- a/internal/ircserver/relay_test.go +++ b/internal/ircserver/relay_test.go @@ -292,3 +292,70 @@ func TestRelayNickDiffersOnlyInCase(t *testing.T) { } } } + +// TestRelayOwnChangeOnce checks that an IRC listener user who +// sets a topic, changes a channel mode or kicks gets one TOPIC, +// MODE or KICK line for it, and so does the other member. +func TestRelayOwnChangeOnce(t *testing.T) { + t.Parallel() + + for _, step := range []struct { + command, line string + }{ + {"TOPIC", "TOPIC #dup :new topic"}, + {"MODE", "MODE #dup +m"}, + {"KICK", "KICK #dup carl :bye"}, + } { + t.Run(step.command, func(t *testing.T) { + t.Parallel() + + env := newTestEnv(t) + + alice := env.dial(t) + alice.register("alice") + alice.joinAndDrain("#dup") + + carl := env.dial(t) + carl.register("carl") + carl.joinAndDrain("#dup") + + alice.send(step.line) + + // The reply to alice's next command comes after her + // command has finished, so her own copy comes before it. + beforePong := alice.sendAndExpect("PING :done", "PONG") + + // The relay sends a user's queue in order, so a message + // queued now arrives after any relayed copy. + for nick, client := range map[string]*testClient{ + "alice": alice, "carl": carl, + } { + env.queueMessage( + t, nick, "PRIVMSG", "dave", nick, + []string{"after the change"}, + ) + + lines := client.readUntil(func(l string) bool { + return strings.HasSuffix(l, " :after the change") + }) + + if client == alice { + lines = slices.Concat(beforePong, lines) + } + + var changes []string + + for _, line := range lines { + if strings.Contains(line, " "+step.command+" #dup ") { + changes = append(changes, line) + } + } + + if len(changes) != 1 { + t.Errorf("%s got %q, want one %s line", + nick, changes, step.command) + } + } + }) + } +}