diff --git a/README.md b/README.md index 600fd51..3c26921 100644 --- a/README.md +++ b/README.md @@ -268,10 +268,12 @@ rules). Case-sensitive comparison is unambiguous. A single user session can have multiple clients (phone, laptop, terminal). - Each client gets a **separate server-to-client (S2C) message queue**. -- The server fans out messages and events from other users, and the user's own - DMs and the channel messages it sends over the HTTP API, to every client queue - for that user session. Replies to a command (numerics) go only to the client - that sent it. +- The server fans out messages and events from other users, and those the user + sends from any of its clients, to every client queue for that user session. + The one exception is an IRC listener connection, which gets nothing queued for + a command it sent itself: it has already sent its user the echo, or, for + `PRIVMSG` and `NOTICE`, IRC sends none. Replies to a command (numerics) go + only to the client that sent it. - `GET /api/v1/messages` delivers from the calling client's specific queue, identified by the auth cookie. - Client queues have **independent expiry/pruning** — one client going offline @@ -760,9 +762,8 @@ Leave a channel. **Behavior:** -- The PART event goes to the other channel members, not to the departing user; - the HTTP response confirms the part. (The IRC listener echoes the PART to its - own connection.) +- The PART event goes to every channel member, the departing user's own clients + included. (An IRC listener connection that sends a PART echoes it itself.) - If the channel is empty after the user leaves, the channel is **deleted** (ephemeral channels). - If the user is not in the channel, the server returns an error. diff --git a/internal/db/queries.go b/internal/db/queries.go index 8b216a1..11aa6f3 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -1208,11 +1208,12 @@ func insertMessageInTx( return dbID, msgUUID, nil } -// EnqueueToSession adds a message to all clients of a -// session's queues. +// EnqueueToSession adds a message to the queues of all +// clients of a session except client skipClientID, or of all +// of them when skipClientID is 0. func (database *Database) EnqueueToSession( ctx context.Context, - sessionID, messageID int64, + sessionID, messageID, skipClientID int64, ) error { transaction, err := database.conn.BeginTx(ctx, nil) if err != nil { @@ -1221,7 +1222,9 @@ func (database *Database) EnqueueToSession( defer func() { _ = transaction.Rollback() }() - err = enqueueToSessionInTx(ctx, transaction, sessionID, messageID) + err = enqueueToSessionInTx( + ctx, transaction, sessionID, messageID, skipClientID, + ) if err != nil { return err } @@ -1239,15 +1242,15 @@ func (database *Database) EnqueueToSession( func enqueueToSessionInTx( ctx context.Context, transaction *sql.Tx, - sessionID, messageID int64, + sessionID, messageID, skipClientID int64, ) error { _, err := transaction.ExecContext(ctx, `INSERT OR IGNORE INTO client_queues (client_id, message_id, created_at) SELECT c.id, ?, ? FROM clients c - WHERE c.session_id = ?`, - messageID, time.Now(), sessionID) + WHERE c.session_id = ? AND c.id != ?`, + messageID, time.Now(), sessionID, skipClientID) if err != nil { return fmt.Errorf( "enqueue to session: %w", err, @@ -1673,7 +1676,7 @@ func quitSessionInTx( for _, peerID := range peerIDs { err = enqueueToSessionInTx( - ctx, transaction, peerID, messageID, + ctx, transaction, peerID, messageID, 0, ) if err != nil { return nil, err diff --git a/internal/db/queries_test.go b/internal/db/queries_test.go index 29f9928..881fec6 100644 --- a/internal/db/queries_test.go +++ b/internal/db/queries_test.go @@ -675,7 +675,7 @@ func TestPollMessages(t *testing.T) { t.Fatal(err) } - err = database.EnqueueToSession(ctx, sid, dbID) + err = database.EnqueueToSession(ctx, sid, dbID, 0) if err != nil { t.Fatal(err) } diff --git a/internal/handlers/api.go b/internal/handlers/api.go index f6f6873..792354d 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -1257,9 +1257,11 @@ func (hdlr *Handlers) handleChannelMsg( ) { ctx := request.Context() - // Delegate validation + fan-out to service layer. - dbID, uuid, err := hdlr.svc.SendChannelMessage( - ctx, sessionID, nick, + // Delegate validation + fan-out to service layer. The + // fan-out includes the sender's own session, so all + // their clients see the message in long-poll responses. + uuid, err := hdlr.svc.SendChannelMessage( + ctx, sessionID, 0, nick, command, target, body, meta, ) if hdlr.handleServiceError( @@ -1269,13 +1271,6 @@ func (hdlr *Handlers) handleChannelMsg( return } - // HTTP echo: enqueue to sender so all their clients - // see the message in long-poll responses. - _ = hdlr.params.Database.EnqueueToSession( - ctx, sessionID, dbID, - ) - hdlr.broker.Notify(sessionID) - hdlr.respondJSON(writer, request, map[string]string{"id": uuid, statusKey: "sent"}, http.StatusOK) @@ -1290,7 +1285,7 @@ func (hdlr *Handlers) handleDirectMsg( meta json.RawMessage, ) { result, err := hdlr.svc.SendDirectMessage( - request.Context(), sessionID, nick, + request.Context(), sessionID, 0, nick, command, target, body, meta, ) if hdlr.handleServiceError( @@ -1361,7 +1356,7 @@ func (hdlr *Handlers) executeJoin( nick, channel, suppliedKey string, ) { result, err := hdlr.svc.JoinChannel( - request.Context(), sessionID, nick, + request.Context(), sessionID, 0, nick, channel, suppliedKey, ) if hdlr.handleServiceError( @@ -1545,7 +1540,7 @@ func (hdlr *Handlers) handlePart( } err := hdlr.svc.PartChannel( - request.Context(), sessionID, + request.Context(), sessionID, 0, nick, channel, reason, ) if hdlr.handleServiceError( @@ -1616,7 +1611,7 @@ func (hdlr *Handlers) executeNickChange( nick, newNick string, ) { err := hdlr.svc.ChangeNick( - request.Context(), sessionID, nick, newNick, + request.Context(), sessionID, 0, nick, newNick, ) if hdlr.handleServiceError( writer, request, @@ -1671,7 +1666,7 @@ func (hdlr *Handlers) handleTopic( topic := strings.Join(lines, " ") err := hdlr.svc.SetTopic( - request.Context(), sessionID, + request.Context(), sessionID, 0, nick, channel, topic, ) if hdlr.handleServiceError( @@ -2157,7 +2152,7 @@ func (hdlr *Handlers) applyUserMode( modeText := modeArgs[0] + " " + targetNick hdlr.svc.BroadcastMode( - request.Context(), nick, channel, chID, + request.Context(), 0, nick, channel, chID, modeText, ) @@ -2203,7 +2198,7 @@ func (hdlr *Handlers) setChannelFlag( } hdlr.svc.BroadcastMode( - request.Context(), nick, channel, chID, + request.Context(), 0, nick, channel, chID, modeStr, ) @@ -3650,7 +3645,7 @@ func (hdlr *Handlers) handleKick( } err := hdlr.svc.KickUser( - request.Context(), sessionID, nick, + request.Context(), sessionID, 0, nick, channel, targetNick, reason, ) if hdlr.handleServiceError( @@ -3723,7 +3718,7 @@ func (hdlr *Handlers) fanOut( for _, sid := range sessionIDs { enqErr := hdlr.params.Database.EnqueueToSession( - request.Context(), sid, dbID, + request.Context(), sid, dbID, 0, ) if enqErr != nil { hdlr.log.Error("enqueue failed", diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index 4d5cb9c..6ca6421 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -2905,7 +2905,7 @@ func (tserver *testServer) queueMessage( return fmt.Errorf("insert message: %w", err) } - err = tserver.database.EnqueueToSession(ctx, sessionID, msgID) + err = tserver.database.EnqueueToSession(ctx, sessionID, msgID, 0) if err != nil { return fmt.Errorf("enqueue: %w", err) } diff --git a/internal/ircserver/commands.go b/internal/ircserver/commands.go index 359a2df..3972026 100644 --- a/internal/ircserver/commands.go +++ b/internal/ircserver/commands.go @@ -84,7 +84,7 @@ func (c *Conn) handleNick( oldNick := c.nick err := c.svc.ChangeNick( - ctx, c.sessionID, oldNick, newNick, + ctx, c.sessionID, c.clientID, oldNick, newNick, ) if err != nil { c.sendIRCError(err) @@ -128,8 +128,8 @@ func (c *Conn) handlePrivmsg( body, _ := json.Marshal([]string{text}) //nolint:errchkjson if strings.HasPrefix(target, "#") { - _, _, err := c.svc.SendChannelMessage( - ctx, c.sessionID, c.nick, + _, err := c.svc.SendChannelMessage( + ctx, c.sessionID, c.clientID, c.nick, msg.Command, target, body, nil, ) if err != nil { @@ -137,7 +137,7 @@ func (c *Conn) handlePrivmsg( } } else { result, err := c.svc.SendDirectMessage( - ctx, c.sessionID, c.nick, + ctx, c.sessionID, c.clientID, c.nick, msg.Command, target, body, nil, ) if err != nil { @@ -199,7 +199,7 @@ func (c *Conn) joinChannel( channel, key string, ) { result, err := c.svc.JoinChannel( - ctx, c.sessionID, c.nick, channel, key, + ctx, c.sessionID, c.clientID, c.nick, channel, key, ) if err != nil { c.sendIRCError(err) @@ -337,7 +337,7 @@ func (c *Conn) partChannel( channel, reason string, ) { err := c.svc.PartChannel( - ctx, c.sessionID, c.nick, channel, reason, + ctx, c.sessionID, c.clientID, c.nick, channel, reason, ) if err != nil { c.sendIRCError(err) @@ -401,7 +401,7 @@ func (c *Conn) handleTopic( newTopic := msg.Params[1] err := c.svc.SetTopic( - ctx, c.sessionID, c.nick, channel, newTopic, + ctx, c.sessionID, c.clientID, c.nick, channel, newTopic, ) if err != nil { c.sendIRCError(err) @@ -708,7 +708,7 @@ func (c *Conn) applyChannelModes( c.send(FormatMessage(c.hostmask(), "MODE", params...)) c.svc.BroadcastMode( - ctx, c.nick, channel, chID, modeReply, + ctx, c.clientID, c.nick, channel, chID, modeReply, ) } } @@ -1185,7 +1185,7 @@ func (c *Conn) handleKick( } err := c.svc.KickUser( - ctx, c.sessionID, c.nick, + ctx, c.sessionID, c.clientID, c.nick, channel, targetNick, reason, ) if err != nil { @@ -1267,7 +1267,7 @@ func (c *Conn) handleInvite( _, _, _ = c.svc.FanOut( //nolint:dogsled // fire-and-forget broadcast ctx, "INVITE", c.nick, targetNick, targetID, 0, - nil, body, nil, []int64{targetID}, + nil, body, nil, []int64{targetID}, 0, ) } diff --git a/internal/ircserver/export_test.go b/internal/ircserver/export_test.go index 69b8b61..5357b7e 100644 --- a/internal/ircserver/export_test.go +++ b/internal/ircserver/export_test.go @@ -71,6 +71,11 @@ func (s *Server) Listener() net.Listener { return s.listener } +// Service returns the service the server's connections use. +func (s *Server) Service() *service.Service { + return s.svc +} + // PingTimes returns how long the server's connections wait // with nothing read before they are sent a PING, and after it // before they are closed. diff --git a/internal/ircserver/relay.go b/internal/ircserver/relay.go index 96125a0..581fb7f 100644 --- a/internal/ircserver/relay.go +++ b/internal/ircserver/relay.go @@ -198,15 +198,6 @@ func (c *Conn) deliverTextMessage( from := msg.From target := msg.To - c.mu.Lock() - nick := c.nick - c.mu.Unlock() - - // Don't echo our own messages back. - if from == nick { - return - } - prefix := from if !strings.Contains(prefix, "!") { prefix = from + "!" + from + "@*" @@ -219,16 +210,6 @@ func (c *Conn) deliverTextMessage( // deliverJoin sends a JOIN notification. func (c *Conn) deliverJoin(msg *db.IRCMessage) { - c.mu.Lock() - nick := c.nick - c.mu.Unlock() - - // Don't echo our own JOINs (we already sent them - // during joinChannel). - if msg.From == nick { - return - } - prefix := msg.From + "!" + msg.From + "@*" channel := msg.To @@ -237,14 +218,6 @@ func (c *Conn) deliverJoin(msg *db.IRCMessage) { // deliverPart sends a PART notification. func (c *Conn) deliverPart(msg *db.IRCMessage, text string) { - c.mu.Lock() - nick := c.nick - c.mu.Unlock() - - if msg.From == nick { - return - } - prefix := msg.From + "!" + msg.From + "@*" channel := msg.To @@ -262,14 +235,6 @@ func (c *Conn) deliverNickChange( msg *db.IRCMessage, newNick string, ) { - c.mu.Lock() - nick := c.nick - c.mu.Unlock() - - if msg.From == nick { - return - } - prefix := msg.From + "!" + msg.From + "@*" c.send(FormatMessage(prefix, "NICK", newNick)) @@ -280,14 +245,6 @@ func (c *Conn) deliverQuitMsg( msg *db.IRCMessage, text string, ) { - c.mu.Lock() - nick := c.nick - c.mu.Unlock() - - if msg.From == nick { - return - } - prefix := msg.From + "!" + msg.From + "@*" if text != "" { @@ -304,15 +261,6 @@ 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 @@ -324,15 +272,6 @@ 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 @@ -403,15 +342,6 @@ 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 c0d4b22..b28f1d4 100644 --- a/internal/ircserver/relay_test.go +++ b/internal/ircserver/relay_test.go @@ -1,6 +1,7 @@ package ircserver_test import ( + "context" "encoding/json" "slices" "strings" @@ -8,6 +9,7 @@ import ( "time" "sneak.berlin/go/neoirc/internal/ircserver" + "sneak.berlin/go/neoirc/internal/service" ) // queueMessage queues a message for the user with nick @@ -39,7 +41,7 @@ func (env *testEnv) queueMessage( t.Fatalf("get session of %s: %v", recipient, err) } - err = env.database.EnqueueToSession(ctx, sessionID, msgID) + err = env.database.EnqueueToSession(ctx, sessionID, msgID, 0) if err != nil { t.Fatalf("enqueue: %v", err) } @@ -391,3 +393,221 @@ func TestRelayOwnChangeOnce(t *testing.T) { }) } } + +// TestRelayOwnSessionOnce checks that a command a user makes +// from one client of their session reaches each of the +// session's clients once: an IRC listener connection and an +// HTTP API client. The connection that sent a command gets +// only its own echo, and none for PRIVMSG and NOTICE, which +// IRC does not echo. +// +//nolint:funlen // table-driven test +func TestRelayOwnSessionOnce(t *testing.T) { + t.Parallel() + + hello := json.RawMessage(`["hello"]`) + + for _, step := range []struct { + // line is the command as alice sends it on the IRC + // listener. + line string + // api makes the same command for her session the way + // the HTTP API does. + api func( + ctx context.Context, svc *service.Service, sessionID int64, + ) error + // echoes is how many copies alice's connection gets of + // line when it sends line itself. + echoes int + }{ + {"PRIVMSG #chan :hello", func( + ctx context.Context, svc *service.Service, sessionID int64, + ) error { + _, err := svc.SendChannelMessage( + ctx, sessionID, 0, "alice", "PRIVMSG", "#chan", hello, nil, + ) + + return err + }, 0}, + {"NOTICE bob :hello", func( + ctx context.Context, svc *service.Service, sessionID int64, + ) error { + _, err := svc.SendDirectMessage( + ctx, sessionID, 0, "alice", "NOTICE", "bob", hello, nil, + ) + + return err + }, 0}, + {"JOIN #other", func( + ctx context.Context, svc *service.Service, sessionID int64, + ) error { + _, err := svc.JoinChannel(ctx, sessionID, 0, "alice", "#other", "") + + return err + }, 1}, + {"PART #chan :bye", func( + ctx context.Context, svc *service.Service, sessionID int64, + ) error { + return svc.PartChannel(ctx, sessionID, 0, "alice", "#chan", "bye") + }, 1}, + {"NICK alice2", func( + ctx context.Context, svc *service.Service, sessionID int64, + ) error { + return svc.ChangeNick(ctx, sessionID, 0, "alice", "alice2") + }, 1}, + {"TOPIC #chan :new topic", func( + ctx context.Context, svc *service.Service, sessionID int64, + ) error { + return svc.SetTopic( + ctx, sessionID, 0, "alice", "#chan", "new topic", + ) + }, 1}, + {"MODE #chan +m", func( + ctx context.Context, svc *service.Service, sessionID int64, + ) error { + chID, err := svc.ValidateChannelOp(ctx, sessionID, "#chan") + if err != nil { + return err + } + + err = svc.SetChannelFlag(ctx, chID, 'm', true) + if err != nil { + return err + } + + svc.BroadcastMode(ctx, 0, "alice", "#chan", chID, "+m") + + return nil + }, 1}, + {"KICK #chan bob :bye", func( + ctx context.Context, svc *service.Service, sessionID int64, + ) error { + return svc.KickUser( + ctx, sessionID, 0, "alice", "#chan", "bob", "bye", + ) + }, 1}, + } { + command := strings.Fields(step.line)[0] + + t.Run(command+" from IRC", func(t *testing.T) { + t.Parallel() + + ircCopies, httpCopies := ownSessionCopies(t, step.line, nil) + if ircCopies != step.echoes || httpCopies != 1 { + t.Errorf("connection got %d, HTTP client got %d, want %d and 1", + ircCopies, httpCopies, step.echoes) + } + }) + + t.Run(command+" from the HTTP API", func(t *testing.T) { + t.Parallel() + + ircCopies, httpCopies := ownSessionCopies(t, step.line, step.api) + if ircCopies != 1 || httpCopies != 1 { + t.Errorf("connection got %d, HTTP client got %d, want 1 and 1", + ircCopies, httpCopies) + } + }) + } +} + +// ownSessionCopies connects alice to the IRC listener and has +// her join #chan, which bob is on too, then adds an HTTP API +// client to her session with the password her connection set, +// as POST /api/v1/login does. It makes the command line from +// her connection, or with api from the HTTP API client when api +// is not nil, and returns how many copies of it her connection +// and her HTTP API client got. +func ownSessionCopies( + t *testing.T, + line string, + api func(ctx context.Context, svc *service.Service, sessionID int64) error, +) (int, int) { + t.Helper() + + const password = "alicepass" + + ctx := t.Context() + env := newTestEnv(t) + + alice := env.dial(t) + alice.send("PASS " + password) + alice.register("alice") + // Registration has stored the password by the time the + // JOIN is handled. + alice.joinAndDrain("#chan") + + bob := env.dial(t) + bob.register("bob") + bob.joinAndDrain("#chan") + + sessionID, httpClientID, _, err := env.database.LoginUser( + ctx, "alice", password, "127.0.0.1", "localhost", + ) + if err != nil { + t.Fatalf("log in as alice: %v", err) + } + + var lines []string + + if api == nil { + alice.send(line) + + // The reply to alice's next command comes after her + // command has finished. + lines = alice.sendAndExpect("PING :done", "PONG") + } else { + err = api(ctx, env.srv.Service(), sessionID) + if err != nil { + t.Fatalf("%s over the HTTP API: %v", line, err) + } + } + + msgs, _, err := env.database.PollMessages(ctx, httpClientID, 0, 0) + if err != nil { + t.Fatalf("poll the HTTP API client: %v", err) + } + + httpCopies := 0 + + for _, msg := range msgs { + if msg.Command == strings.Fields(line)[0] { + httpCopies++ + } + } + + // The relay sends a queue in order, so a message queued now + // reaches alice's connection after any copy of the command. + // It is queued by session, as the command may change her + // nick. + endID, _, err := env.database.InsertMessage( + ctx, "PRIVMSG", "dave", "alice", 0, 0, + nil, json.RawMessage(`["the end"]`), nil, + ) + if err != nil { + t.Fatalf("insert message: %v", err) + } + + err = env.database.EnqueueToSession(ctx, sessionID, endID, 0) + if err != nil { + t.Fatalf("enqueue: %v", err) + } + + env.brk.Notify(sessionID) + + lines = append(lines, alice.readUntil(func(l string) bool { + return strings.HasSuffix(l, " :the end") + })...) + + // A copy starts with the command and its first parameter. + start := " " + strings.Join(strings.Fields(line)[:2], " ") + ircCopies := 0 + + for _, l := range lines { + if strings.Contains(l, start) { + ircCopies++ + } + } + + return ircCopies, httpCopies +} diff --git a/internal/service/service.go b/internal/service/service.go index 716549f..f16340d 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -312,7 +312,7 @@ func (s *Service) SendWallops( _, _, err = s.FanOut( ctx, irc.CmdWallops, nick, "*", 0, 0, - nil, body, nil, recipients, + nil, body, nil, recipients, 0, ) if err != nil { return fmt.Errorf("wallops: %w", err) @@ -347,12 +347,19 @@ type DirectMsgResult struct { // session IDs, notifying each via the broker. toSessionID // and toChannelID are the recipient InsertMessage records // for history, 0 when there is none. +// +// skipClientID is the client whose queue the message is +// not added to: the IRC listener connection that sent the +// command, which has already shown it to its user. The HTTP +// API passes 0, as its clients see their own commands +// through their queues. func (s *Service) FanOut( ctx context.Context, command, from, to string, toSessionID, toChannelID int64, params, body, meta json.RawMessage, sessionIDs []int64, + skipClientID int64, ) (int64, string, error) { dbID, msgUUID, err := s.db.InsertMessage( ctx, command, from, to, @@ -363,58 +370,40 @@ func (s *Service) FanOut( } for _, sid := range sessionIDs { - _ = s.db.EnqueueToSession(ctx, sid, dbID) + _ = s.db.EnqueueToSession(ctx, sid, dbID, skipClientID) s.broker.Notify(sid) } return dbID, msgUUID, nil } -// excludeSession returns a copy of ids without the given -// session. -func excludeSession( - ids []int64, - exclude int64, -) []int64 { - out := make([]int64, 0, len(ids)) - - for _, id := range ids { - if id != exclude { - out = append(out, id) - } - } - - return out -} - // SendChannelMessage checks membership, bans and // moderation, then the hashcash stamp of a PRIVMSG to a +H -// channel, then fans out a message to all channel members -// except the sender. The stamp is checked after the other -// checks so that a message they refuse does not spend it. -// Returns the database row ID, message UUID, and any error. -// The dbID lets callers enqueue the same message to the -// sender when echo is needed (HTTP transport). +// channel, then fans out a message to all channel members, +// the sender's session included, skipping client +// skipClientID as FanOut does. The stamp is checked after +// the other checks so that a message they refuse does not +// spend it. Returns the message UUID and any error. func (s *Service) SendChannelMessage( ctx context.Context, - sessionID int64, + sessionID, skipClientID int64, nick, command, channel string, body, meta json.RawMessage, -) (int64, string, error) { +) (string, error) { noSuchChannel := &IRCError{ irc.ErrNoSuchChannel, []string{channel}, msgNoSuchChannel, } chID, err := s.db.GetChannelByName(ctx, channel) if err != nil { - return 0, "", noSuchChannel + return "", noSuchChannel } isMember, _ := s.db.IsChannelMember( ctx, chID, sessionID, ) if !isMember { - return 0, "", &IRCError{ + return "", &IRCError{ irc.ErrCannotSendToChan, []string{channel}, "Cannot send to channel", @@ -426,7 +415,7 @@ func (s *Service) SendChannelMessage( ctx, chID, sessionID, ) if banErr == nil && isBanned { - return 0, "", &IRCError{ + return "", &IRCError{ irc.ErrCannotSendToChan, []string{channel}, "Cannot send to channel (+b)", @@ -443,7 +432,7 @@ func (s *Service) SendChannelMessage( ) if !isOp && !isVoiced { - return 0, "", &IRCError{ + return "", &IRCError{ irc.ErrCannotSendToChan, []string{channel}, "Cannot send to channel (+m)", @@ -456,35 +445,35 @@ func (s *Service) SendChannelMessage( ctx, chID, channel, body, meta, ) if err != nil { - return 0, "", err + return "", err } } memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID) - recipients := excludeSession(memberIDs, sessionID) - dbID, uuid, fanErr := s.FanOut( + _, uuid, fanErr := s.FanOut( ctx, command, nick, channel, 0, chID, - nil, body, meta, recipients, + nil, body, meta, memberIDs, skipClientID, ) // The channel was deleted after the lookup above. if db.IsForeignKeyConstraintError(fanErr) { - return 0, "", noSuchChannel + return "", noSuchChannel } if fanErr != nil { - return 0, "", fanErr + return "", fanErr } - return dbID, uuid, nil + return uuid, nil } // SendDirectMessage validates the target and sends a -// direct message, returning the message UUID and any away -// message set on the target. +// direct message to the target and the sender's session, +// skipping client skipClientID as FanOut does, and returns +// the message UUID and any away message set on the target. func (s *Service) SendDirectMessage( ctx context.Context, - sessionID int64, + sessionID, skipClientID int64, nick, command, target string, body, meta json.RawMessage, ) (*DirectMsgResult, error) { @@ -506,7 +495,7 @@ func (s *Service) SendDirectMessage( _, uuid, fanErr := s.FanOut( ctx, command, nick, target, targetSID, 0, - nil, body, meta, recipients, + nil, body, meta, recipients, skipClientID, ) // The target's session was deleted after the lookup above. if db.IsForeignKeyConstraintError(fanErr) { @@ -522,11 +511,12 @@ func (s *Service) SendDirectMessage( // JoinChannel creates or joins a channel, making the // first joiner the operator. Fans out the JOIN to all -// channel members. A channel name that validChannelRe -// does not match gets ERR_NOSUCHCHANNEL. +// channel members, skipping client skipClientID as FanOut +// does. A channel name that validChannelRe does not match +// gets ERR_NOSUCHCHANNEL. func (s *Service) JoinChannel( ctx context.Context, - sessionID int64, + sessionID, skipClientID int64, nick, channel, suppliedKey string, ) (*JoinResult, error) { if !validChannelRe.MatchString(channel) { @@ -549,7 +539,7 @@ func (s *Service) JoinChannel( _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast ctx, irc.CmdJoin, nick, channel, 0, chID, - nil, body, nil, memberIDs, + nil, body, nil, memberIDs, skipClientID, ) return &JoinResult{ @@ -558,12 +548,12 @@ func (s *Service) JoinChannel( }, nil } -// PartChannel validates membership, broadcasts PART to -// remaining members, removes the user, and cleans up empty -// channels. +// PartChannel validates membership, broadcasts PART to all +// channel members, skipping client skipClientID as FanOut +// does, removes the user, and cleans up empty channels. func (s *Service) PartChannel( ctx context.Context, - sessionID int64, + sessionID, skipClientID int64, nick, channel, reason string, ) error { chID, err := s.db.GetChannelByName(ctx, channel) @@ -587,12 +577,11 @@ func (s *Service) PartChannel( } memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID) - recipients := excludeSession(memberIDs, sessionID) body, _ := json.Marshal([]string{reason}) //nolint:errchkjson _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast ctx, irc.CmdPart, nick, channel, 0, chID, - nil, body, nil, recipients, + nil, body, nil, memberIDs, skipClientID, ) s.db.PartChannelAndDeleteIfEmpty(ctx, chID, sessionID) //nolint:errcheck,gosec @@ -601,10 +590,11 @@ func (s *Service) PartChannel( } // SetTopic validates membership and topic-lock, sets the -// topic, and broadcasts the change. +// topic, and broadcasts the change to all channel members, +// skipping client skipClientID as FanOut does. func (s *Service) SetTopic( ctx context.Context, - sessionID int64, + sessionID, skipClientID int64, nick, channel, topic string, ) error { chID, err := s.db.GetChannelByName(ctx, channel) @@ -653,18 +643,19 @@ func (s *Service) SetTopic( _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast ctx, irc.CmdTopic, nick, channel, 0, chID, - nil, body, nil, memberIDs, + nil, body, nil, memberIDs, skipClientID, ) return nil } // KickUser validates operator status and target -// membership, broadcasts the KICK, removes the target, -// and cleans up empty channels. +// membership, broadcasts the KICK to all channel members, +// skipping client skipClientID as FanOut does, removes the +// target, and cleans up empty channels. func (s *Service) KickUser( ctx context.Context, - sessionID int64, + sessionID, skipClientID int64, nick, channel, targetNick, reason string, ) error { chID, err := s.db.GetChannelByName(ctx, channel) @@ -717,7 +708,7 @@ func (s *Service) KickUser( _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast ctx, irc.CmdKick, nick, channel, 0, chID, - params, body, nil, memberIDs, + params, body, nil, memberIDs, skipClientID, ) s.db.PartChannelAndDeleteIfEmpty(ctx, chID, targetSID) //nolint:errcheck,gosec @@ -798,10 +789,11 @@ func (s *Service) InviteUser( } // ChangeNick changes a user's nickname and broadcasts the -// change to all users sharing channels. +// change to the user's own session and all users sharing +// channels, skipping client skipClientID as FanOut does. func (s *Service) ChangeNick( ctx context.Context, - sessionID int64, + sessionID, skipClientID int64, oldNick, newNick string, ) error { err := s.db.ChangeNick(ctx, sessionID, newNick) @@ -822,7 +814,9 @@ func (s *Service) ChangeNick( } } - s.broadcastNickChange(ctx, sessionID, oldNick, newNick) + s.broadcastNickChange( + ctx, sessionID, skipClientID, oldNick, newNick, + ) return nil } @@ -1042,9 +1036,10 @@ func (s *Service) SetChannelFlag( } // BroadcastMode fans out a MODE change to all channel -// members. +// members, skipping client skipClientID as FanOut does. func (s *Service) BroadcastMode( ctx context.Context, + skipClientID int64, nick, channel string, chID int64, modeText string, @@ -1054,7 +1049,7 @@ func (s *Service) BroadcastMode( _, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast ctx, irc.CmdMode, nick, channel, 0, chID, - nil, body, nil, memberIDs, + nil, body, nil, memberIDs, skipClientID, ) } @@ -1306,11 +1301,12 @@ func (s *Service) checkChannelHashcash( return nil } -// broadcastNickChange notifies channel peers of a nick +// broadcastNickChange notifies the user's own session, +// except client skipClientID, and channel peers of a nick // change. func (s *Service) broadcastNickChange( ctx context.Context, - sessionID int64, + sessionID, skipClientID int64, oldNick, newNick string, ) { channels, err := s.db.GetSessionChannels( @@ -1332,7 +1328,7 @@ func (s *Service) broadcastNickChange( } // Notify the user themselves (for multi-client sync). - _ = s.db.EnqueueToSession(ctx, sessionID, dbID) + _ = s.db.EnqueueToSession(ctx, sessionID, dbID, skipClientID) s.broker.Notify(sessionID) notified[sessionID] = true @@ -1351,7 +1347,7 @@ func (s *Service) broadcastNickChange( notified[mid] = true - _ = s.db.EnqueueToSession(ctx, mid, dbID) + _ = s.db.EnqueueToSession(ctx, mid, dbID, 0) s.broker.Notify(mid) } } diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 79ba6bb..3ad3279 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -168,7 +168,7 @@ func TestFanOut(t *testing.T) { dbID, uuid, err := env.svc.FanOut( ctx, irc.CmdPrivmsg, "alice", "#test", 0, 0, nil, body, nil, - []int64{sid1, sid2}, + []int64{sid1, sid2}, 0, ) if err != nil { t.Fatalf("FanOut: %v", err) @@ -190,7 +190,7 @@ func TestJoinChannel(t *testing.T) { sid := createSession(ctx, t, env.db, "alice") result, err := env.svc.JoinChannel( - ctx, sid, "alice", "#general", "", + ctx, sid, 0, "alice", "#general", "", ) if err != nil { t.Fatalf("JoinChannel: %v", err) @@ -208,7 +208,7 @@ func TestJoinChannel(t *testing.T) { sid2 := createSession(ctx, t, env.db, "bob") result2, err := env.svc.JoinChannel( - ctx, sid2, "bob", "#general", "", + ctx, sid2, 0, "bob", "#general", "", ) if err != nil { t.Fatalf("JoinChannel bob: %v", err) @@ -230,14 +230,14 @@ func TestPartChannel(t *testing.T) { sid := createSession(ctx, t, env.db, "alice") _, err := env.svc.JoinChannel( - ctx, sid, "alice", "#general", "", + ctx, sid, 0, "alice", "#general", "", ) if err != nil { t.Fatalf("JoinChannel: %v", err) } err = env.svc.PartChannel( - ctx, sid, "alice", "#general", "bye", + ctx, sid, 0, "alice", "#general", "bye", ) if err != nil { t.Fatalf("PartChannel: %v", err) @@ -245,7 +245,7 @@ func TestPartChannel(t *testing.T) { // Parting a non-existent channel returns error. err = env.svc.PartChannel( - ctx, sid, "alice", "#nonexistent", "", + ctx, sid, 0, "alice", "#nonexistent", "", ) if err == nil { t.Error("expected error for non-existent channel") @@ -309,7 +309,7 @@ func joinConcurrently( joins[i] = func() error { result, err := env.svc.JoinChannel( - ctx, sessionID, nick, channel, "", + ctx, sessionID, 0, nick, channel, "", ) results[i] = result @@ -335,7 +335,7 @@ func TestConcurrentJoinsRespectUserLimit(t *testing.T) { opSID := createSession(ctx, t, env.db, opNick) created, err := env.svc.JoinChannel( - ctx, opSID, opNick, channel, "", + ctx, opSID, 0, opNick, channel, "", ) if err != nil { t.Fatalf("join %s: %v", opNick, err) @@ -448,7 +448,7 @@ func TestJoinConcurrentWithLastPart(t *testing.T) { joinerSID := createSession(ctx, t, env.db, joinerNick) _, err := env.svc.JoinChannel( - ctx, leaverSID, leaverNick, channel, "", + ctx, leaverSID, 0, leaverNick, channel, "", ) if err != nil { t.Fatalf("join %s: %v", leaverNick, err) @@ -457,12 +457,12 @@ func TestJoinConcurrentWithLastPart(t *testing.T) { errs := runConcurrently( func() error { return env.svc.PartChannel( - ctx, leaverSID, leaverNick, channel, "", + ctx, leaverSID, 0, leaverNick, channel, "", ) }, func() error { _, joinErr := env.svc.JoinChannel( - ctx, joinerSID, joinerNick, channel, "", + ctx, joinerSID, 0, joinerNick, channel, "", ) return joinErr @@ -513,7 +513,7 @@ func TestJoinConcurrentWithLastQuit(t *testing.T) { joinerSID := createSession(ctx, t, env.db, joinerNick) created, err := env.svc.JoinChannel( - ctx, leaverSID, leaverNick, channel, "", + ctx, leaverSID, 0, leaverNick, channel, "", ) if err != nil { t.Fatalf("join %s: %v", leaverNick, err) @@ -545,7 +545,7 @@ func TestJoinConcurrentWithLastQuit(t *testing.T) { ) joined, joinErr = env.svc.JoinChannel( - ctx, joinerSID, joinerNick, channel, "", + ctx, joinerSID, 0, joinerNick, channel, "", ) if !errors.As(joinErr, &ircErr) || ircErr.Code != irc.ErrBadChannelKey { @@ -592,7 +592,7 @@ func TestQuitQueuedAheadOfNewNickHoldersJoin(t *testing.T) { quitterSID := createSession(ctx, t, env.db, nick) for _, channel := range []string{crowd, shared} { - _, err := env.svc.JoinChannel(ctx, quitterSID, nick, channel, "") + _, err := env.svc.JoinChannel(ctx, quitterSID, 0, nick, channel, "") if err != nil { t.Fatalf("join %s: %v", channel, err) } @@ -602,7 +602,7 @@ func TestQuitQueuedAheadOfNewNickHoldersJoin(t *testing.T) { memberNick := fmt.Sprintf("crowd%dx%d", trial, member) memberSID := createSession(ctx, t, env.db, memberNick) - _, err := env.svc.JoinChannel(ctx, memberSID, memberNick, crowd, "") + _, err := env.svc.JoinChannel(ctx, memberSID, 0, memberNick, crowd, "") if err != nil { t.Fatalf("join %s: %v", memberNick, err) } @@ -617,7 +617,7 @@ func TestQuitQueuedAheadOfNewNickHoldersJoin(t *testing.T) { t.Fatalf("create session %s: %v", watcherNick, err) } - _, err = env.svc.JoinChannel(ctx, watcherSID, watcherNick, shared, "") + _, err = env.svc.JoinChannel(ctx, watcherSID, 0, watcherNick, shared, "") if err != nil { t.Fatalf("join %s: %v", watcherNick, err) } @@ -678,7 +678,7 @@ func takeNickAndJoin( return err } - _, err = env.svc.JoinChannel(ctx, sessionID, nick, channel, "") + _, err = env.svc.JoinChannel(ctx, sessionID, 0, nick, channel, "") return err } @@ -710,7 +710,7 @@ func TestJoinUsesUpInvite(t *testing.T) { opSID := createSession(ctx, t, env.db, "op") guestSID := createSession(ctx, t, env.db, "guest") - created, err := env.svc.JoinChannel(ctx, opSID, "op", channel, "") + created, err := env.svc.JoinChannel(ctx, opSID, 0, "op", channel, "") if err != nil { t.Fatalf("join op: %v", err) } @@ -725,17 +725,17 @@ func TestJoinUsesUpInvite(t *testing.T) { t.Fatalf("invite guest: %v", err) } - _, err = env.svc.JoinChannel(ctx, guestSID, "guest", channel, "") + _, err = env.svc.JoinChannel(ctx, guestSID, 0, "guest", channel, "") if err != nil { t.Fatalf("invited join: %v", err) } - err = env.svc.PartChannel(ctx, guestSID, "guest", channel, "") + err = env.svc.PartChannel(ctx, guestSID, 0, "guest", channel, "") if err != nil { t.Fatalf("part guest: %v", err) } - _, err = env.svc.JoinChannel(ctx, guestSID, "guest", channel, "") + _, err = env.svc.JoinChannel(ctx, guestSID, 0, "guest", channel, "") var ircErr *service.IRCError if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrInviteOnlyChan { @@ -754,7 +754,7 @@ func TestJoinRefusedByHostmaskBan(t *testing.T) { opSID := createSession(ctx, t, env.db, "op") - created, err := env.svc.JoinChannel(ctx, opSID, "op", channel, "") + created, err := env.svc.JoinChannel(ctx, opSID, 0, "op", channel, "") if err != nil { t.Fatalf("join op: %v", err) } @@ -773,7 +773,7 @@ func TestJoinRefusedByHostmaskBan(t *testing.T) { t.Fatalf("create session guest: %v", err) } - _, err = env.svc.JoinChannel(ctx, guestSID, "guest", channel, "") + _, err = env.svc.JoinChannel(ctx, guestSID, 0, "guest", channel, "") var ircErr *service.IRCError if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrBannedFromChan { @@ -793,17 +793,17 @@ func TestPartDeletesChannelOnlyWhenEmpty(t *testing.T) { aliceSID := createSession(ctx, t, env.db, "alice") bobSID := createSession(ctx, t, env.db, "bob") - _, err := env.svc.JoinChannel(ctx, aliceSID, "alice", channel, "") + _, err := env.svc.JoinChannel(ctx, aliceSID, 0, "alice", channel, "") if err != nil { t.Fatalf("join alice: %v", err) } - _, err = env.svc.JoinChannel(ctx, bobSID, "bob", channel, "") + _, err = env.svc.JoinChannel(ctx, bobSID, 0, "bob", channel, "") if err != nil { t.Fatalf("join bob: %v", err) } - err = env.svc.PartChannel(ctx, aliceSID, "alice", channel, "") + err = env.svc.PartChannel(ctx, aliceSID, 0, "alice", channel, "") if err != nil { t.Fatalf("part alice: %v", err) } @@ -818,7 +818,7 @@ func TestPartDeletesChannelOnlyWhenEmpty(t *testing.T) { t.Fatal("bob is not on the channel after alice parted") } - err = env.svc.PartChannel(ctx, bobSID, "bob", channel, "") + err = env.svc.PartChannel(ctx, bobSID, 0, "bob", channel, "") if err != nil { t.Fatalf("part bob: %v", err) } @@ -839,12 +839,12 @@ func TestKickOfLastMemberDeletesChannel(t *testing.T) { sid := createSession(ctx, t, env.db, "alice") - _, err := env.svc.JoinChannel(ctx, sid, "alice", channel, "") + _, err := env.svc.JoinChannel(ctx, sid, 0, "alice", channel, "") if err != nil { t.Fatalf("join alice: %v", err) } - err = env.svc.KickUser(ctx, sid, "alice", channel, "alice", "") + err = env.svc.KickUser(ctx, sid, 0, "alice", channel, "alice", "") if err != nil { t.Fatalf("kick alice: %v", err) } @@ -863,14 +863,14 @@ func TestSendChannelMessage(t *testing.T) { sid2 := createSession(ctx, t, env.db, "bob") _, err := env.svc.JoinChannel( - ctx, sid1, "alice", "#chat", "", + ctx, sid1, 0, "alice", "#chat", "", ) if err != nil { t.Fatalf("join alice: %v", err) } _, err = env.svc.JoinChannel( - ctx, sid2, "bob", "#chat", "", + ctx, sid2, 0, "bob", "#chat", "", ) if err != nil { t.Fatalf("join bob: %v", err) @@ -878,18 +878,14 @@ func TestSendChannelMessage(t *testing.T) { body, _ := json.Marshal([]string{"hello world"}) //nolint:errchkjson - dbID, uuid, err := env.svc.SendChannelMessage( - ctx, sid1, "alice", + uuid, err := env.svc.SendChannelMessage( + ctx, sid1, 0, "alice", irc.CmdPrivmsg, "#chat", body, nil, ) if err != nil { t.Fatalf("SendChannelMessage: %v", err) } - if dbID == 0 { - t.Error("expected non-zero dbID") - } - if uuid == "" { t.Error("expected non-empty UUID") } @@ -897,8 +893,8 @@ func TestSendChannelMessage(t *testing.T) { // Non-member cannot send. sid3 := createSession(ctx, t, env.db, "charlie") - _, _, err = env.svc.SendChannelMessage( - ctx, sid3, "charlie", + _, err = env.svc.SendChannelMessage( + ctx, sid3, 0, "charlie", irc.CmdPrivmsg, "#chat", body, nil, ) if err == nil { @@ -914,14 +910,14 @@ func TestBroadcastQuit(t *testing.T) { sid2 := createSession(ctx, t, env.db, "bob") _, err := env.svc.JoinChannel( - ctx, sid1, "alice", "#room", "", + ctx, sid1, 0, "alice", "#room", "", ) if err != nil { t.Fatalf("join alice: %v", err) } _, err = env.svc.JoinChannel( - ctx, sid2, "bob", "#room", "", + ctx, sid2, 0, "bob", "#room", "", ) if err != nil { t.Fatalf("join bob: %v", err) @@ -963,7 +959,7 @@ func TestSendDirectMessageTargetQuits(t *testing.T) { body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson _, err = env.svc.SendDirectMessage( - ctx, sid, "alice", irc.CmdPrivmsg, "bob", body, nil, + ctx, sid, 0, "alice", irc.CmdPrivmsg, "bob", body, nil, ) var ircErr *service.IRCError @@ -983,7 +979,7 @@ func TestSendChannelMessageChannelDeleted(t *testing.T) { sid := createSession(ctx, t, env.db, "alice") - _, err := env.svc.JoinChannel(ctx, sid, "alice", "#gone", "") + _, err := env.svc.JoinChannel(ctx, sid, 0, "alice", "#gone", "") if err != nil { t.Fatalf("join alice: %v", err) } @@ -1000,8 +996,8 @@ func TestSendChannelMessageChannelDeleted(t *testing.T) { body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson - _, _, err = env.svc.SendChannelMessage( - ctx, sid, "alice", irc.CmdPrivmsg, "#gone", body, nil, + _, err = env.svc.SendChannelMessage( + ctx, sid, 0, "alice", irc.CmdPrivmsg, "#gone", body, nil, ) var ircErr *service.IRCError @@ -1019,14 +1015,14 @@ func TestSendChannelMessage_Moderated(t *testing.T) { sid2 := createSession(ctx, t, env.db, "bob") result, err := env.svc.JoinChannel( - ctx, sid1, "alice", "#modchat", "", + ctx, sid1, 0, "alice", "#modchat", "", ) if err != nil { t.Fatalf("join alice: %v", err) } _, err = env.svc.JoinChannel( - ctx, sid2, "bob", "#modchat", "", + ctx, sid2, 0, "bob", "#modchat", "", ) if err != nil { t.Fatalf("join bob: %v", err) @@ -1039,8 +1035,8 @@ func TestSendChannelMessage_Moderated(t *testing.T) { body, _ := json.Marshal([]string{"test"}) //nolint:errchkjson // Bob (non-op, non-voiced) should fail to send. - _, _, err = env.svc.SendChannelMessage( - ctx, sid2, "bob", + _, err = env.svc.SendChannelMessage( + ctx, sid2, 0, "bob", irc.CmdPrivmsg, "#modchat", body, nil, ) if err == nil { @@ -1048,8 +1044,8 @@ func TestSendChannelMessage_Moderated(t *testing.T) { } // Alice (operator) should succeed. - _, _, err = env.svc.SendChannelMessage( - ctx, sid1, "alice", + _, err = env.svc.SendChannelMessage( + ctx, sid1, 0, "alice", irc.CmdPrivmsg, "#modchat", body, nil, ) if err != nil { @@ -1074,7 +1070,7 @@ func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) { refuse func(ctx context.Context, env *testEnv, chID, bobSID int64) error }{ {"not a member", func(ctx context.Context, env *testEnv, _, bobSID int64) error { - return env.svc.PartChannel(ctx, bobSID, "bob", channel, "") + return env.svc.PartChannel(ctx, bobSID, 0, "bob", channel, "") }}, {"banned", func(ctx context.Context, env *testEnv, chID, _ int64) error { return env.db.AddChannelBan(ctx, chID, "bob!*@*", "alice") @@ -1094,12 +1090,12 @@ func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) { aliceSID := createSession(ctx, t, env.db, "alice") bobSID := createSession(ctx, t, env.db, "bob") - joined, err := env.svc.JoinChannel(ctx, aliceSID, "alice", channel, "") + joined, err := env.svc.JoinChannel(ctx, aliceSID, 0, "alice", channel, "") if err != nil { t.Fatalf("join alice: %v", err) } - _, err = env.svc.JoinChannel(ctx, bobSID, "bob", channel, "") + _, err = env.svc.JoinChannel(ctx, bobSID, 0, "bob", channel, "") if err != nil { t.Fatalf("join bob: %v", err) } @@ -1119,8 +1115,8 @@ func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) { ) meta, _ := json.Marshal(map[string]string{"hashcash": stamp}) - _, _, err = env.svc.SendChannelMessage( - ctx, bobSID, "bob", irc.CmdPrivmsg, channel, body, meta, + _, err = env.svc.SendChannelMessage( + ctx, bobSID, 0, "bob", irc.CmdPrivmsg, channel, body, meta, ) var ircErr *service.IRCError @@ -1129,8 +1125,8 @@ func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) { t.Fatalf("bob: want ERR_CANNOTSENDTOCHAN, got %v", err) } - _, _, err = env.svc.SendChannelMessage( - ctx, aliceSID, "alice", irc.CmdPrivmsg, channel, body, meta, + _, err = env.svc.SendChannelMessage( + ctx, aliceSID, 0, "alice", irc.CmdPrivmsg, channel, body, meta, ) if err != nil { t.Fatalf("alice with the same stamp: %v", err)