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) + } + } + }) + } +}