Show an IRC listener user what their session sends from other clients (closes #179)
check / check (push) Waiting to run

The relay dropped every PRIVMSG, NOTICE, JOIN, PART, NICK, QUIT, TOPIC,
MODE and KICK sent under the connection's own nick, so an IRC listener
connection never saw what its session did from another client. The
relay now drops nothing by nick. Instead, the service methods that fan
out a user's own command take the client whose queue to skip: the IRC
listener passes its own client, which has already written the echo, and
the HTTP API passes 0. Channel messages and PARTs now also go to the
sender's own session, so the HTTP handler no longer queues the
channel-message echo itself, SendChannelMessage no longer returns the
message's row ID, and neoirc-cli ignores the PART event for its own
part, which it has already shown.

Model: opus-5-5
This commit is contained in:
2026-10-21 11:59:38 +00:00
committed by sneak
parent d539b4c558
commit b2119a7fe5
13 changed files with 455 additions and 247 deletions
+12 -8
View File
@@ -268,10 +268,15 @@ 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 to every client queue
for that user session.
- When the user sends a `PRIVMSG`, `NOTICE`, `JOIN`, `PART`, `NICK`, `TOPIC`,
`MODE` or `KICK` from one of its clients, the resulting event goes to every
client queue of the user's own session, as well as to the other users it is
meant for. The one exception is an IRC listener connection that sent the
command, which gets nothing queued for it: 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
@@ -744,7 +749,7 @@ Leave a channel.
{"command": "PART", "to": "#general", "body": ["goodbye"]}
```
**S2C (broadcast to the other channel members):**
**S2C (broadcast to all channel members, including the departing user):**
```json
{
@@ -760,9 +765,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.
+7 -3
View File
@@ -757,7 +757,7 @@ func (a *App) handleServerMessage(msg *api.Message) {
case irc.CmdJoin:
a.handleJoinEvent(msg, timestamp)
case irc.CmdPart:
a.handlePartEvent(msg, timestamp)
a.handlePartEvent(msg, timestamp, myNick)
case irc.CmdQuit:
a.handleQuitEvent(msg, timestamp)
case irc.CmdNick:
@@ -813,10 +813,14 @@ func (a *App) handleJoinEvent(
))
}
// handlePartEvent shows another user's part. The server
// also sends the user's own part back to it, which cmdPart
// has already shown; showing it again would add a second
// line and mark the channel just left as unread.
func (a *App) handlePartEvent(
msg *api.Message, timestamp string,
msg *api.Message, timestamp, myNick string,
) {
if msg.To == "" {
if msg.To == "" || msg.From == myNick {
return
}
+65
View File
@@ -3,7 +3,9 @@ package cli
import (
"testing"
"github.com/gdamore/tcell/v2"
api "sneak.berlin/go/neoirc/internal/cli/api"
"sneak.berlin/go/neoirc/pkg/irc"
)
func TestMemberPrefix(t *testing.T) {
@@ -49,3 +51,66 @@ func TestMemberPrefix(t *testing.T) {
})
}
}
// TestPartEvent checks that a PART event adds a line to the
// channel's buffer and marks it unread, except for the
// user's own part, which cmdPart has already shown.
func TestPartEvent(t *testing.T) {
t.Parallel()
tests := []struct {
name string
from string
wantLines int
wantUnread int
}{
{name: "own part", from: "alice", wantLines: 0, wantUnread: 0},
{name: "other member", from: "bob", wantLines: 1, wantUnread: 1},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
app := &App{ui: NewUI(), nick: "alice"}
runUI(t, app.ui)
app.handleServerMessage(&api.Message{
Command: irc.CmdPart, From: test.from, To: "#chan",
})
var lines, unread int
app.ui.app.QueueUpdate(func() {
buf := app.ui.getOrCreateBuffer("#chan")
lines = len(buf.Lines)
unread = buf.Unread
})
if lines != test.wantLines || unread != test.wantUnread {
t.Fatalf("got %d lines and %d unread, want %d and %d",
lines, unread, test.wantLines, test.wantUnread)
}
})
}
}
// runUI runs ui on a simulated screen until the test ends.
func runUI(t *testing.T, ui *UI) {
t.Helper()
ui.app.SetScreen(tcell.NewSimulationScreen(""))
done := make(chan error)
go func() { done <- ui.Run() }()
t.Cleanup(func() {
ui.Stop()
err := <-done
if err != nil {
t.Errorf("run ui: %v", err)
}
})
}
+11 -8
View File
@@ -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
+1 -1
View File
@@ -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)
}
+14 -19
View File
@@ -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",
+1 -1
View File
@@ -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)
}
+10 -10
View File
@@ -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,
)
}
+5
View File
@@ -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.
-70
View File
@@ -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(
+211 -1
View File
@@ -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,211 @@ 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 stores the password before the JOIN runs.
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)
// Her echo comes before the reply to her next command.
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 this arrives after any
// copy. It is queued by session, as NICK changes 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 := strings.Count(strings.Join(lines, "\n"), start)
return ircCopies, httpCopies
}
+64 -68
View File
@@ -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)
}
}
+54 -58
View File
@@ -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)