Show an IRC listener user what their session sends from other clients (closes #179) #183
@@ -268,10 +268,15 @@ rules). Case-sensitive comparison is unambiguous.
|
|||||||
A single user session can have multiple clients (phone, laptop, terminal).
|
A single user session can have multiple clients (phone, laptop, terminal).
|
||||||
|
|
||||||
- Each client gets a **separate server-to-client (S2C) message queue**.
|
- 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
|
- The server fans out messages and events from other users to every client queue
|
||||||
DMs and the channel messages it sends over the HTTP API, to every client queue
|
for that user session.
|
||||||
for that user session. Replies to a command (numerics) go only to the client
|
- When the user sends a `PRIVMSG`, `NOTICE`, `JOIN`, `PART`, `NICK`, `TOPIC`,
|
||||||
that sent it.
|
`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,
|
- `GET /api/v1/messages` delivers from the calling client's specific queue,
|
||||||
identified by the auth cookie.
|
identified by the auth cookie.
|
||||||
- Client queues have **independent expiry/pruning** — one client going offline
|
- Client queues have **independent expiry/pruning** — one client going offline
|
||||||
@@ -744,7 +749,7 @@ Leave a channel.
|
|||||||
{"command": "PART", "to": "#general", "body": ["goodbye"]}
|
{"command": "PART", "to": "#general", "body": ["goodbye"]}
|
||||||
```
|
```
|
||||||
|
|
||||||
**S2C (broadcast to the other channel members):**
|
**S2C (broadcast to all channel members, including the departing user):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -760,9 +765,8 @@ Leave a channel.
|
|||||||
|
|
||||||
**Behavior:**
|
**Behavior:**
|
||||||
|
|
||||||
- The PART event goes to the other channel members, not to the departing user;
|
- The PART event goes to every channel member, the departing user's own clients
|
||||||
the HTTP response confirms the part. (The IRC listener echoes the PART to its
|
included. (An IRC listener connection that sends a PART echoes it itself.)
|
||||||
own connection.)
|
|
||||||
- If the channel is empty after the user leaves, the channel is **deleted**
|
- If the channel is empty after the user leaves, the channel is **deleted**
|
||||||
(ephemeral channels).
|
(ephemeral channels).
|
||||||
- If the user is not in the channel, the server returns an error.
|
- If the user is not in the channel, the server returns an error.
|
||||||
|
|||||||
+7
-3
@@ -757,7 +757,7 @@ func (a *App) handleServerMessage(msg *api.Message) {
|
|||||||
case irc.CmdJoin:
|
case irc.CmdJoin:
|
||||||
a.handleJoinEvent(msg, timestamp)
|
a.handleJoinEvent(msg, timestamp)
|
||||||
case irc.CmdPart:
|
case irc.CmdPart:
|
||||||
a.handlePartEvent(msg, timestamp)
|
a.handlePartEvent(msg, timestamp, myNick)
|
||||||
case irc.CmdQuit:
|
case irc.CmdQuit:
|
||||||
a.handleQuitEvent(msg, timestamp)
|
a.handleQuitEvent(msg, timestamp)
|
||||||
case irc.CmdNick:
|
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(
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
api "sneak.berlin/go/neoirc/internal/cli/api"
|
api "sneak.berlin/go/neoirc/internal/cli/api"
|
||||||
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMemberPrefix(t *testing.T) {
|
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: "erin", 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: "erin"}
|
||||||
|
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
@@ -1208,11 +1208,12 @@ func insertMessageInTx(
|
|||||||
return dbID, msgUUID, nil
|
return dbID, msgUUID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnqueueToSession adds a message to all clients of a
|
// EnqueueToSession adds a message to the queues of all
|
||||||
// session's queues.
|
// clients of a session except client skipClientID, or of all
|
||||||
|
// of them when skipClientID is 0.
|
||||||
func (database *Database) EnqueueToSession(
|
func (database *Database) EnqueueToSession(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sessionID, messageID int64,
|
sessionID, messageID, skipClientID int64,
|
||||||
) error {
|
) error {
|
||||||
transaction, err := database.conn.BeginTx(ctx, nil)
|
transaction, err := database.conn.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1221,7 +1222,9 @@ func (database *Database) EnqueueToSession(
|
|||||||
|
|
||||||
defer func() { _ = transaction.Rollback() }()
|
defer func() { _ = transaction.Rollback() }()
|
||||||
|
|
||||||
err = enqueueToSessionInTx(ctx, transaction, sessionID, messageID)
|
err = enqueueToSessionInTx(
|
||||||
|
ctx, transaction, sessionID, messageID, skipClientID,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1239,15 +1242,15 @@ func (database *Database) EnqueueToSession(
|
|||||||
func enqueueToSessionInTx(
|
func enqueueToSessionInTx(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
transaction *sql.Tx,
|
transaction *sql.Tx,
|
||||||
sessionID, messageID int64,
|
sessionID, messageID, skipClientID int64,
|
||||||
) error {
|
) error {
|
||||||
_, err := transaction.ExecContext(ctx,
|
_, err := transaction.ExecContext(ctx,
|
||||||
`INSERT OR IGNORE INTO client_queues
|
`INSERT OR IGNORE INTO client_queues
|
||||||
(client_id, message_id, created_at)
|
(client_id, message_id, created_at)
|
||||||
SELECT c.id, ?, ?
|
SELECT c.id, ?, ?
|
||||||
FROM clients c
|
FROM clients c
|
||||||
WHERE c.session_id = ?`,
|
WHERE c.session_id = ? AND c.id != ?`,
|
||||||
messageID, time.Now(), sessionID)
|
messageID, time.Now(), sessionID, skipClientID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"enqueue to session: %w", err,
|
"enqueue to session: %w", err,
|
||||||
@@ -1673,7 +1676,7 @@ func quitSessionInTx(
|
|||||||
|
|
||||||
for _, peerID := range peerIDs {
|
for _, peerID := range peerIDs {
|
||||||
err = enqueueToSessionInTx(
|
err = enqueueToSessionInTx(
|
||||||
ctx, transaction, peerID, messageID,
|
ctx, transaction, peerID, messageID, 0,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -675,7 +675,7 @@ func TestPollMessages(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = database.EnqueueToSession(ctx, sid, dbID)
|
err = database.EnqueueToSession(ctx, sid, dbID, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-19
@@ -1257,9 +1257,11 @@ func (hdlr *Handlers) handleChannelMsg(
|
|||||||
) {
|
) {
|
||||||
ctx := request.Context()
|
ctx := request.Context()
|
||||||
|
|
||||||
// Delegate validation + fan-out to service layer.
|
// Delegate validation + fan-out to service layer. The
|
||||||
dbID, uuid, err := hdlr.svc.SendChannelMessage(
|
// fan-out includes the sender's own session, so all
|
||||||
ctx, sessionID, nick,
|
// their clients see the message in long-poll responses.
|
||||||
|
uuid, err := hdlr.svc.SendChannelMessage(
|
||||||
|
ctx, sessionID, 0, nick,
|
||||||
command, target, body, meta,
|
command, target, body, meta,
|
||||||
)
|
)
|
||||||
if hdlr.handleServiceError(
|
if hdlr.handleServiceError(
|
||||||
@@ -1269,13 +1271,6 @@ func (hdlr *Handlers) handleChannelMsg(
|
|||||||
return
|
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,
|
hdlr.respondJSON(writer, request,
|
||||||
map[string]string{"id": uuid, statusKey: "sent"},
|
map[string]string{"id": uuid, statusKey: "sent"},
|
||||||
http.StatusOK)
|
http.StatusOK)
|
||||||
@@ -1290,7 +1285,7 @@ func (hdlr *Handlers) handleDirectMsg(
|
|||||||
meta json.RawMessage,
|
meta json.RawMessage,
|
||||||
) {
|
) {
|
||||||
result, err := hdlr.svc.SendDirectMessage(
|
result, err := hdlr.svc.SendDirectMessage(
|
||||||
request.Context(), sessionID, nick,
|
request.Context(), sessionID, 0, nick,
|
||||||
command, target, body, meta,
|
command, target, body, meta,
|
||||||
)
|
)
|
||||||
if hdlr.handleServiceError(
|
if hdlr.handleServiceError(
|
||||||
@@ -1361,7 +1356,7 @@ func (hdlr *Handlers) executeJoin(
|
|||||||
nick, channel, suppliedKey string,
|
nick, channel, suppliedKey string,
|
||||||
) {
|
) {
|
||||||
result, err := hdlr.svc.JoinChannel(
|
result, err := hdlr.svc.JoinChannel(
|
||||||
request.Context(), sessionID, nick,
|
request.Context(), sessionID, 0, nick,
|
||||||
channel, suppliedKey,
|
channel, suppliedKey,
|
||||||
)
|
)
|
||||||
if hdlr.handleServiceError(
|
if hdlr.handleServiceError(
|
||||||
@@ -1545,7 +1540,7 @@ func (hdlr *Handlers) handlePart(
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := hdlr.svc.PartChannel(
|
err := hdlr.svc.PartChannel(
|
||||||
request.Context(), sessionID,
|
request.Context(), sessionID, 0,
|
||||||
nick, channel, reason,
|
nick, channel, reason,
|
||||||
)
|
)
|
||||||
if hdlr.handleServiceError(
|
if hdlr.handleServiceError(
|
||||||
@@ -1616,7 +1611,7 @@ func (hdlr *Handlers) executeNickChange(
|
|||||||
nick, newNick string,
|
nick, newNick string,
|
||||||
) {
|
) {
|
||||||
err := hdlr.svc.ChangeNick(
|
err := hdlr.svc.ChangeNick(
|
||||||
request.Context(), sessionID, nick, newNick,
|
request.Context(), sessionID, 0, nick, newNick,
|
||||||
)
|
)
|
||||||
if hdlr.handleServiceError(
|
if hdlr.handleServiceError(
|
||||||
writer, request,
|
writer, request,
|
||||||
@@ -1671,7 +1666,7 @@ func (hdlr *Handlers) handleTopic(
|
|||||||
topic := strings.Join(lines, " ")
|
topic := strings.Join(lines, " ")
|
||||||
|
|
||||||
err := hdlr.svc.SetTopic(
|
err := hdlr.svc.SetTopic(
|
||||||
request.Context(), sessionID,
|
request.Context(), sessionID, 0,
|
||||||
nick, channel, topic,
|
nick, channel, topic,
|
||||||
)
|
)
|
||||||
if hdlr.handleServiceError(
|
if hdlr.handleServiceError(
|
||||||
@@ -2157,7 +2152,7 @@ func (hdlr *Handlers) applyUserMode(
|
|||||||
|
|
||||||
modeText := modeArgs[0] + " " + targetNick
|
modeText := modeArgs[0] + " " + targetNick
|
||||||
hdlr.svc.BroadcastMode(
|
hdlr.svc.BroadcastMode(
|
||||||
request.Context(), nick, channel, chID,
|
request.Context(), 0, nick, channel, chID,
|
||||||
modeText,
|
modeText,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2203,7 +2198,7 @@ func (hdlr *Handlers) setChannelFlag(
|
|||||||
}
|
}
|
||||||
|
|
||||||
hdlr.svc.BroadcastMode(
|
hdlr.svc.BroadcastMode(
|
||||||
request.Context(), nick, channel, chID,
|
request.Context(), 0, nick, channel, chID,
|
||||||
modeStr,
|
modeStr,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3650,7 +3645,7 @@ func (hdlr *Handlers) handleKick(
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := hdlr.svc.KickUser(
|
err := hdlr.svc.KickUser(
|
||||||
request.Context(), sessionID, nick,
|
request.Context(), sessionID, 0, nick,
|
||||||
channel, targetNick, reason,
|
channel, targetNick, reason,
|
||||||
)
|
)
|
||||||
if hdlr.handleServiceError(
|
if hdlr.handleServiceError(
|
||||||
@@ -3723,7 +3718,7 @@ func (hdlr *Handlers) fanOut(
|
|||||||
|
|
||||||
for _, sid := range sessionIDs {
|
for _, sid := range sessionIDs {
|
||||||
enqErr := hdlr.params.Database.EnqueueToSession(
|
enqErr := hdlr.params.Database.EnqueueToSession(
|
||||||
request.Context(), sid, dbID,
|
request.Context(), sid, dbID, 0,
|
||||||
)
|
)
|
||||||
if enqErr != nil {
|
if enqErr != nil {
|
||||||
hdlr.log.Error("enqueue failed",
|
hdlr.log.Error("enqueue failed",
|
||||||
|
|||||||
@@ -2905,7 +2905,7 @@ func (tserver *testServer) queueMessage(
|
|||||||
return fmt.Errorf("insert message: %w", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("enqueue: %w", err)
|
return fmt.Errorf("enqueue: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ func (c *Conn) handleNick(
|
|||||||
oldNick := c.nick
|
oldNick := c.nick
|
||||||
|
|
||||||
err := c.svc.ChangeNick(
|
err := c.svc.ChangeNick(
|
||||||
ctx, c.sessionID, oldNick, newNick,
|
ctx, c.sessionID, c.clientID, oldNick, newNick,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.sendIRCError(err)
|
c.sendIRCError(err)
|
||||||
@@ -128,8 +128,8 @@ func (c *Conn) handlePrivmsg(
|
|||||||
body, _ := json.Marshal([]string{text}) //nolint:errchkjson
|
body, _ := json.Marshal([]string{text}) //nolint:errchkjson
|
||||||
|
|
||||||
if strings.HasPrefix(target, "#") {
|
if strings.HasPrefix(target, "#") {
|
||||||
_, _, err := c.svc.SendChannelMessage(
|
_, err := c.svc.SendChannelMessage(
|
||||||
ctx, c.sessionID, c.nick,
|
ctx, c.sessionID, c.clientID, c.nick,
|
||||||
msg.Command, target, body, nil,
|
msg.Command, target, body, nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -137,7 +137,7 @@ func (c *Conn) handlePrivmsg(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
result, err := c.svc.SendDirectMessage(
|
result, err := c.svc.SendDirectMessage(
|
||||||
ctx, c.sessionID, c.nick,
|
ctx, c.sessionID, c.clientID, c.nick,
|
||||||
msg.Command, target, body, nil,
|
msg.Command, target, body, nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -199,7 +199,7 @@ func (c *Conn) joinChannel(
|
|||||||
channel, key string,
|
channel, key string,
|
||||||
) {
|
) {
|
||||||
result, err := c.svc.JoinChannel(
|
result, err := c.svc.JoinChannel(
|
||||||
ctx, c.sessionID, c.nick, channel, key,
|
ctx, c.sessionID, c.clientID, c.nick, channel, key,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.sendIRCError(err)
|
c.sendIRCError(err)
|
||||||
@@ -337,7 +337,7 @@ func (c *Conn) partChannel(
|
|||||||
channel, reason string,
|
channel, reason string,
|
||||||
) {
|
) {
|
||||||
err := c.svc.PartChannel(
|
err := c.svc.PartChannel(
|
||||||
ctx, c.sessionID, c.nick, channel, reason,
|
ctx, c.sessionID, c.clientID, c.nick, channel, reason,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.sendIRCError(err)
|
c.sendIRCError(err)
|
||||||
@@ -401,7 +401,7 @@ func (c *Conn) handleTopic(
|
|||||||
newTopic := msg.Params[1]
|
newTopic := msg.Params[1]
|
||||||
|
|
||||||
err := c.svc.SetTopic(
|
err := c.svc.SetTopic(
|
||||||
ctx, c.sessionID, c.nick, channel, newTopic,
|
ctx, c.sessionID, c.clientID, c.nick, channel, newTopic,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.sendIRCError(err)
|
c.sendIRCError(err)
|
||||||
@@ -708,7 +708,7 @@ func (c *Conn) applyChannelModes(
|
|||||||
c.send(FormatMessage(c.hostmask(), "MODE", params...))
|
c.send(FormatMessage(c.hostmask(), "MODE", params...))
|
||||||
|
|
||||||
c.svc.BroadcastMode(
|
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(
|
err := c.svc.KickUser(
|
||||||
ctx, c.sessionID, c.nick,
|
ctx, c.sessionID, c.clientID, c.nick,
|
||||||
channel, targetNick, reason,
|
channel, targetNick, reason,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1267,7 +1267,7 @@ func (c *Conn) handleInvite(
|
|||||||
|
|
||||||
_, _, _ = c.svc.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
_, _, _ = c.svc.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||||
ctx, "INVITE", c.nick, targetNick, targetID, 0,
|
ctx, "INVITE", c.nick, targetNick, targetID, 0,
|
||||||
nil, body, nil, []int64{targetID},
|
nil, body, nil, []int64{targetID}, 0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ func (s *Server) Listener() net.Listener {
|
|||||||
return s.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
|
// PingTimes returns how long the server's connections wait
|
||||||
// with nothing read before they are sent a PING, and after it
|
// with nothing read before they are sent a PING, and after it
|
||||||
// before they are closed.
|
// before they are closed.
|
||||||
|
|||||||
@@ -198,15 +198,6 @@ func (c *Conn) deliverTextMessage(
|
|||||||
from := msg.From
|
from := msg.From
|
||||||
target := msg.To
|
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
|
prefix := from
|
||||||
if !strings.Contains(prefix, "!") {
|
if !strings.Contains(prefix, "!") {
|
||||||
prefix = from + "!" + from + "@*"
|
prefix = from + "!" + from + "@*"
|
||||||
@@ -219,16 +210,6 @@ func (c *Conn) deliverTextMessage(
|
|||||||
|
|
||||||
// deliverJoin sends a JOIN notification.
|
// deliverJoin sends a JOIN notification.
|
||||||
func (c *Conn) deliverJoin(msg *db.IRCMessage) {
|
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 + "@*"
|
prefix := msg.From + "!" + msg.From + "@*"
|
||||||
channel := msg.To
|
channel := msg.To
|
||||||
|
|
||||||
@@ -237,14 +218,6 @@ func (c *Conn) deliverJoin(msg *db.IRCMessage) {
|
|||||||
|
|
||||||
// deliverPart sends a PART notification.
|
// deliverPart sends a PART notification.
|
||||||
func (c *Conn) deliverPart(msg *db.IRCMessage, text string) {
|
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 + "@*"
|
prefix := msg.From + "!" + msg.From + "@*"
|
||||||
channel := msg.To
|
channel := msg.To
|
||||||
|
|
||||||
@@ -262,14 +235,6 @@ func (c *Conn) deliverNickChange(
|
|||||||
msg *db.IRCMessage,
|
msg *db.IRCMessage,
|
||||||
newNick string,
|
newNick string,
|
||||||
) {
|
) {
|
||||||
c.mu.Lock()
|
|
||||||
nick := c.nick
|
|
||||||
c.mu.Unlock()
|
|
||||||
|
|
||||||
if msg.From == nick {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
prefix := msg.From + "!" + msg.From + "@*"
|
prefix := msg.From + "!" + msg.From + "@*"
|
||||||
|
|
||||||
c.send(FormatMessage(prefix, "NICK", newNick))
|
c.send(FormatMessage(prefix, "NICK", newNick))
|
||||||
@@ -280,14 +245,6 @@ func (c *Conn) deliverQuitMsg(
|
|||||||
msg *db.IRCMessage,
|
msg *db.IRCMessage,
|
||||||
text string,
|
text string,
|
||||||
) {
|
) {
|
||||||
c.mu.Lock()
|
|
||||||
nick := c.nick
|
|
||||||
c.mu.Unlock()
|
|
||||||
|
|
||||||
if msg.From == nick {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
prefix := msg.From + "!" + msg.From + "@*"
|
prefix := msg.From + "!" + msg.From + "@*"
|
||||||
|
|
||||||
if text != "" {
|
if text != "" {
|
||||||
@@ -304,15 +261,6 @@ func (c *Conn) deliverTopicChange(
|
|||||||
msg *db.IRCMessage,
|
msg *db.IRCMessage,
|
||||||
text string,
|
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 + "@*"
|
prefix := msg.From + "!" + msg.From + "@*"
|
||||||
channel := msg.To
|
channel := msg.To
|
||||||
|
|
||||||
@@ -324,15 +272,6 @@ func (c *Conn) deliverKickMsg(
|
|||||||
msg *db.IRCMessage,
|
msg *db.IRCMessage,
|
||||||
text string,
|
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 + "@*"
|
prefix := msg.From + "!" + msg.From + "@*"
|
||||||
channel := msg.To
|
channel := msg.To
|
||||||
|
|
||||||
@@ -403,15 +342,6 @@ func (c *Conn) deliverMode(
|
|||||||
return
|
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 + "@*"
|
prefix := msg.From + "!" + msg.From + "@*"
|
||||||
|
|
||||||
params := slices.Concat(
|
params := slices.Concat(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ircserver_test
|
package ircserver_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -8,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/neoirc/internal/ircserver"
|
"sneak.berlin/go/neoirc/internal/ircserver"
|
||||||
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// queueMessage queues a message for the user with nick
|
// 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)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("enqueue: %v", err)
|
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
@@ -312,7 +312,7 @@ func (s *Service) SendWallops(
|
|||||||
|
|
||||||
_, _, err = s.FanOut(
|
_, _, err = s.FanOut(
|
||||||
ctx, irc.CmdWallops, nick, "*", 0, 0,
|
ctx, irc.CmdWallops, nick, "*", 0, 0,
|
||||||
nil, body, nil, recipients,
|
nil, body, nil, recipients, 0,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("wallops: %w", err)
|
return fmt.Errorf("wallops: %w", err)
|
||||||
@@ -347,12 +347,19 @@ type DirectMsgResult struct {
|
|||||||
// session IDs, notifying each via the broker. toSessionID
|
// session IDs, notifying each via the broker. toSessionID
|
||||||
// and toChannelID are the recipient InsertMessage records
|
// and toChannelID are the recipient InsertMessage records
|
||||||
// for history, 0 when there is none.
|
// 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(
|
func (s *Service) FanOut(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
command, from, to string,
|
command, from, to string,
|
||||||
toSessionID, toChannelID int64,
|
toSessionID, toChannelID int64,
|
||||||
params, body, meta json.RawMessage,
|
params, body, meta json.RawMessage,
|
||||||
sessionIDs []int64,
|
sessionIDs []int64,
|
||||||
|
skipClientID int64,
|
||||||
) (int64, string, error) {
|
) (int64, string, error) {
|
||||||
dbID, msgUUID, err := s.db.InsertMessage(
|
dbID, msgUUID, err := s.db.InsertMessage(
|
||||||
ctx, command, from, to,
|
ctx, command, from, to,
|
||||||
@@ -363,58 +370,40 @@ func (s *Service) FanOut(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, sid := range sessionIDs {
|
for _, sid := range sessionIDs {
|
||||||
_ = s.db.EnqueueToSession(ctx, sid, dbID)
|
_ = s.db.EnqueueToSession(ctx, sid, dbID, skipClientID)
|
||||||
s.broker.Notify(sid)
|
s.broker.Notify(sid)
|
||||||
}
|
}
|
||||||
|
|
||||||
return dbID, msgUUID, nil
|
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
|
// SendChannelMessage checks membership, bans and
|
||||||
// moderation, then the hashcash stamp of a PRIVMSG to a +H
|
// moderation, then the hashcash stamp of a PRIVMSG to a +H
|
||||||
// channel, then fans out a message to all channel members
|
// channel, then fans out a message to all channel members,
|
||||||
// except the sender. The stamp is checked after the other
|
// the sender's session included, skipping client
|
||||||
// checks so that a message they refuse does not spend it.
|
// skipClientID as FanOut does. The stamp is checked after
|
||||||
// Returns the database row ID, message UUID, and any error.
|
// the other checks so that a message they refuse does not
|
||||||
// The dbID lets callers enqueue the same message to the
|
// spend it. Returns the message UUID and any error.
|
||||||
// sender when echo is needed (HTTP transport).
|
|
||||||
func (s *Service) SendChannelMessage(
|
func (s *Service) SendChannelMessage(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sessionID int64,
|
sessionID, skipClientID int64,
|
||||||
nick, command, channel string,
|
nick, command, channel string,
|
||||||
body, meta json.RawMessage,
|
body, meta json.RawMessage,
|
||||||
) (int64, string, error) {
|
) (string, error) {
|
||||||
noSuchChannel := &IRCError{
|
noSuchChannel := &IRCError{
|
||||||
irc.ErrNoSuchChannel, []string{channel}, msgNoSuchChannel,
|
irc.ErrNoSuchChannel, []string{channel}, msgNoSuchChannel,
|
||||||
}
|
}
|
||||||
|
|
||||||
chID, err := s.db.GetChannelByName(ctx, channel)
|
chID, err := s.db.GetChannelByName(ctx, channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", noSuchChannel
|
return "", noSuchChannel
|
||||||
}
|
}
|
||||||
|
|
||||||
isMember, _ := s.db.IsChannelMember(
|
isMember, _ := s.db.IsChannelMember(
|
||||||
ctx, chID, sessionID,
|
ctx, chID, sessionID,
|
||||||
)
|
)
|
||||||
if !isMember {
|
if !isMember {
|
||||||
return 0, "", &IRCError{
|
return "", &IRCError{
|
||||||
irc.ErrCannotSendToChan,
|
irc.ErrCannotSendToChan,
|
||||||
[]string{channel},
|
[]string{channel},
|
||||||
"Cannot send to channel",
|
"Cannot send to channel",
|
||||||
@@ -426,7 +415,7 @@ func (s *Service) SendChannelMessage(
|
|||||||
ctx, chID, sessionID,
|
ctx, chID, sessionID,
|
||||||
)
|
)
|
||||||
if banErr == nil && isBanned {
|
if banErr == nil && isBanned {
|
||||||
return 0, "", &IRCError{
|
return "", &IRCError{
|
||||||
irc.ErrCannotSendToChan,
|
irc.ErrCannotSendToChan,
|
||||||
[]string{channel},
|
[]string{channel},
|
||||||
"Cannot send to channel (+b)",
|
"Cannot send to channel (+b)",
|
||||||
@@ -443,7 +432,7 @@ func (s *Service) SendChannelMessage(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if !isOp && !isVoiced {
|
if !isOp && !isVoiced {
|
||||||
return 0, "", &IRCError{
|
return "", &IRCError{
|
||||||
irc.ErrCannotSendToChan,
|
irc.ErrCannotSendToChan,
|
||||||
[]string{channel},
|
[]string{channel},
|
||||||
"Cannot send to channel (+m)",
|
"Cannot send to channel (+m)",
|
||||||
@@ -456,35 +445,35 @@ func (s *Service) SendChannelMessage(
|
|||||||
ctx, chID, channel, body, meta,
|
ctx, chID, channel, body, meta,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", err
|
return "", err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
|
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,
|
ctx, command, nick, channel, 0, chID,
|
||||||
nil, body, meta, recipients,
|
nil, body, meta, memberIDs, skipClientID,
|
||||||
)
|
)
|
||||||
// The channel was deleted after the lookup above.
|
// The channel was deleted after the lookup above.
|
||||||
if db.IsForeignKeyConstraintError(fanErr) {
|
if db.IsForeignKeyConstraintError(fanErr) {
|
||||||
return 0, "", noSuchChannel
|
return "", noSuchChannel
|
||||||
}
|
}
|
||||||
|
|
||||||
if fanErr != nil {
|
if fanErr != nil {
|
||||||
return 0, "", fanErr
|
return "", fanErr
|
||||||
}
|
}
|
||||||
|
|
||||||
return dbID, uuid, nil
|
return uuid, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendDirectMessage validates the target and sends a
|
// SendDirectMessage validates the target and sends a
|
||||||
// direct message, returning the message UUID and any away
|
// direct message to the target and the sender's session,
|
||||||
// message set on the target.
|
// skipping client skipClientID as FanOut does, and returns
|
||||||
|
// the message UUID and any away message set on the target.
|
||||||
func (s *Service) SendDirectMessage(
|
func (s *Service) SendDirectMessage(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sessionID int64,
|
sessionID, skipClientID int64,
|
||||||
nick, command, target string,
|
nick, command, target string,
|
||||||
body, meta json.RawMessage,
|
body, meta json.RawMessage,
|
||||||
) (*DirectMsgResult, error) {
|
) (*DirectMsgResult, error) {
|
||||||
@@ -506,7 +495,7 @@ func (s *Service) SendDirectMessage(
|
|||||||
|
|
||||||
_, uuid, fanErr := s.FanOut(
|
_, uuid, fanErr := s.FanOut(
|
||||||
ctx, command, nick, target, targetSID, 0,
|
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.
|
// The target's session was deleted after the lookup above.
|
||||||
if db.IsForeignKeyConstraintError(fanErr) {
|
if db.IsForeignKeyConstraintError(fanErr) {
|
||||||
@@ -522,11 +511,12 @@ func (s *Service) SendDirectMessage(
|
|||||||
|
|
||||||
// JoinChannel creates or joins a channel, making the
|
// JoinChannel creates or joins a channel, making the
|
||||||
// first joiner the operator. Fans out the JOIN to all
|
// first joiner the operator. Fans out the JOIN to all
|
||||||
// channel members. A channel name that validChannelRe
|
// channel members, skipping client skipClientID as FanOut
|
||||||
// does not match gets ERR_NOSUCHCHANNEL.
|
// does. A channel name that validChannelRe does not match
|
||||||
|
// gets ERR_NOSUCHCHANNEL.
|
||||||
func (s *Service) JoinChannel(
|
func (s *Service) JoinChannel(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sessionID int64,
|
sessionID, skipClientID int64,
|
||||||
nick, channel, suppliedKey string,
|
nick, channel, suppliedKey string,
|
||||||
) (*JoinResult, error) {
|
) (*JoinResult, error) {
|
||||||
if !validChannelRe.MatchString(channel) {
|
if !validChannelRe.MatchString(channel) {
|
||||||
@@ -549,7 +539,7 @@ func (s *Service) JoinChannel(
|
|||||||
|
|
||||||
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||||
ctx, irc.CmdJoin, nick, channel, 0, chID,
|
ctx, irc.CmdJoin, nick, channel, 0, chID,
|
||||||
nil, body, nil, memberIDs,
|
nil, body, nil, memberIDs, skipClientID,
|
||||||
)
|
)
|
||||||
|
|
||||||
return &JoinResult{
|
return &JoinResult{
|
||||||
@@ -558,12 +548,12 @@ func (s *Service) JoinChannel(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PartChannel validates membership, broadcasts PART to
|
// PartChannel validates membership, broadcasts PART to all
|
||||||
// remaining members, removes the user, and cleans up empty
|
// channel members, skipping client skipClientID as FanOut
|
||||||
// channels.
|
// does, removes the user, and cleans up empty channels.
|
||||||
func (s *Service) PartChannel(
|
func (s *Service) PartChannel(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sessionID int64,
|
sessionID, skipClientID int64,
|
||||||
nick, channel, reason string,
|
nick, channel, reason string,
|
||||||
) error {
|
) error {
|
||||||
chID, err := s.db.GetChannelByName(ctx, channel)
|
chID, err := s.db.GetChannelByName(ctx, channel)
|
||||||
@@ -587,12 +577,11 @@ func (s *Service) PartChannel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
|
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
|
||||||
recipients := excludeSession(memberIDs, sessionID)
|
|
||||||
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
||||||
|
|
||||||
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||||
ctx, irc.CmdPart, nick, channel, 0, chID,
|
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
|
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
|
// 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(
|
func (s *Service) SetTopic(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sessionID int64,
|
sessionID, skipClientID int64,
|
||||||
nick, channel, topic string,
|
nick, channel, topic string,
|
||||||
) error {
|
) error {
|
||||||
chID, err := s.db.GetChannelByName(ctx, channel)
|
chID, err := s.db.GetChannelByName(ctx, channel)
|
||||||
@@ -653,18 +643,19 @@ func (s *Service) SetTopic(
|
|||||||
|
|
||||||
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||||
ctx, irc.CmdTopic, nick, channel, 0, chID,
|
ctx, irc.CmdTopic, nick, channel, 0, chID,
|
||||||
nil, body, nil, memberIDs,
|
nil, body, nil, memberIDs, skipClientID,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// KickUser validates operator status and target
|
// KickUser validates operator status and target
|
||||||
// membership, broadcasts the KICK, removes the target,
|
// membership, broadcasts the KICK to all channel members,
|
||||||
// and cleans up empty channels.
|
// skipping client skipClientID as FanOut does, removes the
|
||||||
|
// target, and cleans up empty channels.
|
||||||
func (s *Service) KickUser(
|
func (s *Service) KickUser(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sessionID int64,
|
sessionID, skipClientID int64,
|
||||||
nick, channel, targetNick, reason string,
|
nick, channel, targetNick, reason string,
|
||||||
) error {
|
) error {
|
||||||
chID, err := s.db.GetChannelByName(ctx, channel)
|
chID, err := s.db.GetChannelByName(ctx, channel)
|
||||||
@@ -717,7 +708,7 @@ func (s *Service) KickUser(
|
|||||||
|
|
||||||
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||||
ctx, irc.CmdKick, nick, channel, 0, chID,
|
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
|
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
|
// 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(
|
func (s *Service) ChangeNick(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sessionID int64,
|
sessionID, skipClientID int64,
|
||||||
oldNick, newNick string,
|
oldNick, newNick string,
|
||||||
) error {
|
) error {
|
||||||
err := s.db.ChangeNick(ctx, sessionID, newNick)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1042,9 +1036,10 @@ func (s *Service) SetChannelFlag(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BroadcastMode fans out a MODE change to all channel
|
// BroadcastMode fans out a MODE change to all channel
|
||||||
// members.
|
// members, skipping client skipClientID as FanOut does.
|
||||||
func (s *Service) BroadcastMode(
|
func (s *Service) BroadcastMode(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
skipClientID int64,
|
||||||
nick, channel string,
|
nick, channel string,
|
||||||
chID int64,
|
chID int64,
|
||||||
modeText string,
|
modeText string,
|
||||||
@@ -1054,7 +1049,7 @@ func (s *Service) BroadcastMode(
|
|||||||
|
|
||||||
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||||
ctx, irc.CmdMode, nick, channel, 0, chID,
|
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
|
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.
|
// change.
|
||||||
func (s *Service) broadcastNickChange(
|
func (s *Service) broadcastNickChange(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sessionID int64,
|
sessionID, skipClientID int64,
|
||||||
oldNick, newNick string,
|
oldNick, newNick string,
|
||||||
) {
|
) {
|
||||||
channels, err := s.db.GetSessionChannels(
|
channels, err := s.db.GetSessionChannels(
|
||||||
@@ -1332,7 +1328,7 @@ func (s *Service) broadcastNickChange(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Notify the user themselves (for multi-client sync).
|
// 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)
|
s.broker.Notify(sessionID)
|
||||||
notified[sessionID] = true
|
notified[sessionID] = true
|
||||||
|
|
||||||
@@ -1351,7 +1347,7 @@ func (s *Service) broadcastNickChange(
|
|||||||
|
|
||||||
notified[mid] = true
|
notified[mid] = true
|
||||||
|
|
||||||
_ = s.db.EnqueueToSession(ctx, mid, dbID)
|
_ = s.db.EnqueueToSession(ctx, mid, dbID, 0)
|
||||||
s.broker.Notify(mid)
|
s.broker.Notify(mid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ func TestFanOut(t *testing.T) {
|
|||||||
dbID, uuid, err := env.svc.FanOut(
|
dbID, uuid, err := env.svc.FanOut(
|
||||||
ctx, irc.CmdPrivmsg, "alice", "#test", 0, 0,
|
ctx, irc.CmdPrivmsg, "alice", "#test", 0, 0,
|
||||||
nil, body, nil,
|
nil, body, nil,
|
||||||
[]int64{sid1, sid2},
|
[]int64{sid1, sid2}, 0,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("FanOut: %v", err)
|
t.Fatalf("FanOut: %v", err)
|
||||||
@@ -190,7 +190,7 @@ func TestJoinChannel(t *testing.T) {
|
|||||||
sid := createSession(ctx, t, env.db, "alice")
|
sid := createSession(ctx, t, env.db, "alice")
|
||||||
|
|
||||||
result, err := env.svc.JoinChannel(
|
result, err := env.svc.JoinChannel(
|
||||||
ctx, sid, "alice", "#general", "",
|
ctx, sid, 0, "alice", "#general", "",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("JoinChannel: %v", err)
|
t.Fatalf("JoinChannel: %v", err)
|
||||||
@@ -208,7 +208,7 @@ func TestJoinChannel(t *testing.T) {
|
|||||||
sid2 := createSession(ctx, t, env.db, "bob")
|
sid2 := createSession(ctx, t, env.db, "bob")
|
||||||
|
|
||||||
result2, err := env.svc.JoinChannel(
|
result2, err := env.svc.JoinChannel(
|
||||||
ctx, sid2, "bob", "#general", "",
|
ctx, sid2, 0, "bob", "#general", "",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("JoinChannel bob: %v", err)
|
t.Fatalf("JoinChannel bob: %v", err)
|
||||||
@@ -230,14 +230,14 @@ func TestPartChannel(t *testing.T) {
|
|||||||
sid := createSession(ctx, t, env.db, "alice")
|
sid := createSession(ctx, t, env.db, "alice")
|
||||||
|
|
||||||
_, err := env.svc.JoinChannel(
|
_, err := env.svc.JoinChannel(
|
||||||
ctx, sid, "alice", "#general", "",
|
ctx, sid, 0, "alice", "#general", "",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("JoinChannel: %v", err)
|
t.Fatalf("JoinChannel: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = env.svc.PartChannel(
|
err = env.svc.PartChannel(
|
||||||
ctx, sid, "alice", "#general", "bye",
|
ctx, sid, 0, "alice", "#general", "bye",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("PartChannel: %v", err)
|
t.Fatalf("PartChannel: %v", err)
|
||||||
@@ -245,7 +245,7 @@ func TestPartChannel(t *testing.T) {
|
|||||||
|
|
||||||
// Parting a non-existent channel returns error.
|
// Parting a non-existent channel returns error.
|
||||||
err = env.svc.PartChannel(
|
err = env.svc.PartChannel(
|
||||||
ctx, sid, "alice", "#nonexistent", "",
|
ctx, sid, 0, "alice", "#nonexistent", "",
|
||||||
)
|
)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error for non-existent channel")
|
t.Error("expected error for non-existent channel")
|
||||||
@@ -309,7 +309,7 @@ func joinConcurrently(
|
|||||||
|
|
||||||
joins[i] = func() error {
|
joins[i] = func() error {
|
||||||
result, err := env.svc.JoinChannel(
|
result, err := env.svc.JoinChannel(
|
||||||
ctx, sessionID, nick, channel, "",
|
ctx, sessionID, 0, nick, channel, "",
|
||||||
)
|
)
|
||||||
results[i] = result
|
results[i] = result
|
||||||
|
|
||||||
@@ -335,7 +335,7 @@ func TestConcurrentJoinsRespectUserLimit(t *testing.T) {
|
|||||||
opSID := createSession(ctx, t, env.db, opNick)
|
opSID := createSession(ctx, t, env.db, opNick)
|
||||||
|
|
||||||
created, err := env.svc.JoinChannel(
|
created, err := env.svc.JoinChannel(
|
||||||
ctx, opSID, opNick, channel, "",
|
ctx, opSID, 0, opNick, channel, "",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("join %s: %v", opNick, err)
|
t.Fatalf("join %s: %v", opNick, err)
|
||||||
@@ -448,7 +448,7 @@ func TestJoinConcurrentWithLastPart(t *testing.T) {
|
|||||||
joinerSID := createSession(ctx, t, env.db, joinerNick)
|
joinerSID := createSession(ctx, t, env.db, joinerNick)
|
||||||
|
|
||||||
_, err := env.svc.JoinChannel(
|
_, err := env.svc.JoinChannel(
|
||||||
ctx, leaverSID, leaverNick, channel, "",
|
ctx, leaverSID, 0, leaverNick, channel, "",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("join %s: %v", leaverNick, err)
|
t.Fatalf("join %s: %v", leaverNick, err)
|
||||||
@@ -457,12 +457,12 @@ func TestJoinConcurrentWithLastPart(t *testing.T) {
|
|||||||
errs := runConcurrently(
|
errs := runConcurrently(
|
||||||
func() error {
|
func() error {
|
||||||
return env.svc.PartChannel(
|
return env.svc.PartChannel(
|
||||||
ctx, leaverSID, leaverNick, channel, "",
|
ctx, leaverSID, 0, leaverNick, channel, "",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
func() error {
|
func() error {
|
||||||
_, joinErr := env.svc.JoinChannel(
|
_, joinErr := env.svc.JoinChannel(
|
||||||
ctx, joinerSID, joinerNick, channel, "",
|
ctx, joinerSID, 0, joinerNick, channel, "",
|
||||||
)
|
)
|
||||||
|
|
||||||
return joinErr
|
return joinErr
|
||||||
@@ -513,7 +513,7 @@ func TestJoinConcurrentWithLastQuit(t *testing.T) {
|
|||||||
joinerSID := createSession(ctx, t, env.db, joinerNick)
|
joinerSID := createSession(ctx, t, env.db, joinerNick)
|
||||||
|
|
||||||
created, err := env.svc.JoinChannel(
|
created, err := env.svc.JoinChannel(
|
||||||
ctx, leaverSID, leaverNick, channel, "",
|
ctx, leaverSID, 0, leaverNick, channel, "",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("join %s: %v", leaverNick, err)
|
t.Fatalf("join %s: %v", leaverNick, err)
|
||||||
@@ -545,7 +545,7 @@ func TestJoinConcurrentWithLastQuit(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
joined, joinErr = env.svc.JoinChannel(
|
joined, joinErr = env.svc.JoinChannel(
|
||||||
ctx, joinerSID, joinerNick, channel, "",
|
ctx, joinerSID, 0, joinerNick, channel, "",
|
||||||
)
|
)
|
||||||
if !errors.As(joinErr, &ircErr) ||
|
if !errors.As(joinErr, &ircErr) ||
|
||||||
ircErr.Code != irc.ErrBadChannelKey {
|
ircErr.Code != irc.ErrBadChannelKey {
|
||||||
@@ -592,7 +592,7 @@ func TestQuitQueuedAheadOfNewNickHoldersJoin(t *testing.T) {
|
|||||||
quitterSID := createSession(ctx, t, env.db, nick)
|
quitterSID := createSession(ctx, t, env.db, nick)
|
||||||
|
|
||||||
for _, channel := range []string{crowd, shared} {
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("join %s: %v", channel, err)
|
t.Fatalf("join %s: %v", channel, err)
|
||||||
}
|
}
|
||||||
@@ -602,7 +602,7 @@ func TestQuitQueuedAheadOfNewNickHoldersJoin(t *testing.T) {
|
|||||||
memberNick := fmt.Sprintf("crowd%dx%d", trial, member)
|
memberNick := fmt.Sprintf("crowd%dx%d", trial, member)
|
||||||
memberSID := createSession(ctx, t, env.db, memberNick)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("join %s: %v", memberNick, err)
|
t.Fatalf("join %s: %v", memberNick, err)
|
||||||
}
|
}
|
||||||
@@ -617,7 +617,7 @@ func TestQuitQueuedAheadOfNewNickHoldersJoin(t *testing.T) {
|
|||||||
t.Fatalf("create session %s: %v", watcherNick, err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("join %s: %v", watcherNick, err)
|
t.Fatalf("join %s: %v", watcherNick, err)
|
||||||
}
|
}
|
||||||
@@ -678,7 +678,7 @@ func takeNickAndJoin(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = env.svc.JoinChannel(ctx, sessionID, nick, channel, "")
|
_, err = env.svc.JoinChannel(ctx, sessionID, 0, nick, channel, "")
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -710,7 +710,7 @@ func TestJoinUsesUpInvite(t *testing.T) {
|
|||||||
opSID := createSession(ctx, t, env.db, "op")
|
opSID := createSession(ctx, t, env.db, "op")
|
||||||
guestSID := createSession(ctx, t, env.db, "guest")
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("join op: %v", err)
|
t.Fatalf("join op: %v", err)
|
||||||
}
|
}
|
||||||
@@ -725,17 +725,17 @@ func TestJoinUsesUpInvite(t *testing.T) {
|
|||||||
t.Fatalf("invite guest: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("invited join: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("part guest: %v", err)
|
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
|
var ircErr *service.IRCError
|
||||||
if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrInviteOnlyChan {
|
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")
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("join op: %v", err)
|
t.Fatalf("join op: %v", err)
|
||||||
}
|
}
|
||||||
@@ -773,7 +773,7 @@ func TestJoinRefusedByHostmaskBan(t *testing.T) {
|
|||||||
t.Fatalf("create session guest: %v", err)
|
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
|
var ircErr *service.IRCError
|
||||||
if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrBannedFromChan {
|
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")
|
aliceSID := createSession(ctx, t, env.db, "alice")
|
||||||
bobSID := createSession(ctx, t, env.db, "bob")
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("join alice: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("join bob: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("part alice: %v", err)
|
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")
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("part bob: %v", err)
|
t.Fatalf("part bob: %v", err)
|
||||||
}
|
}
|
||||||
@@ -839,12 +839,12 @@ func TestKickOfLastMemberDeletesChannel(t *testing.T) {
|
|||||||
|
|
||||||
sid := createSession(ctx, t, env.db, "alice")
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("join alice: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("kick alice: %v", err)
|
t.Fatalf("kick alice: %v", err)
|
||||||
}
|
}
|
||||||
@@ -863,14 +863,14 @@ func TestSendChannelMessage(t *testing.T) {
|
|||||||
sid2 := createSession(ctx, t, env.db, "bob")
|
sid2 := createSession(ctx, t, env.db, "bob")
|
||||||
|
|
||||||
_, err := env.svc.JoinChannel(
|
_, err := env.svc.JoinChannel(
|
||||||
ctx, sid1, "alice", "#chat", "",
|
ctx, sid1, 0, "alice", "#chat", "",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("join alice: %v", err)
|
t.Fatalf("join alice: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = env.svc.JoinChannel(
|
_, err = env.svc.JoinChannel(
|
||||||
ctx, sid2, "bob", "#chat", "",
|
ctx, sid2, 0, "bob", "#chat", "",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("join bob: %v", err)
|
t.Fatalf("join bob: %v", err)
|
||||||
@@ -878,18 +878,14 @@ func TestSendChannelMessage(t *testing.T) {
|
|||||||
|
|
||||||
body, _ := json.Marshal([]string{"hello world"}) //nolint:errchkjson
|
body, _ := json.Marshal([]string{"hello world"}) //nolint:errchkjson
|
||||||
|
|
||||||
dbID, uuid, err := env.svc.SendChannelMessage(
|
uuid, err := env.svc.SendChannelMessage(
|
||||||
ctx, sid1, "alice",
|
ctx, sid1, 0, "alice",
|
||||||
irc.CmdPrivmsg, "#chat", body, nil,
|
irc.CmdPrivmsg, "#chat", body, nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SendChannelMessage: %v", err)
|
t.Fatalf("SendChannelMessage: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if dbID == 0 {
|
|
||||||
t.Error("expected non-zero dbID")
|
|
||||||
}
|
|
||||||
|
|
||||||
if uuid == "" {
|
if uuid == "" {
|
||||||
t.Error("expected non-empty UUID")
|
t.Error("expected non-empty UUID")
|
||||||
}
|
}
|
||||||
@@ -897,8 +893,8 @@ func TestSendChannelMessage(t *testing.T) {
|
|||||||
// Non-member cannot send.
|
// Non-member cannot send.
|
||||||
sid3 := createSession(ctx, t, env.db, "charlie")
|
sid3 := createSession(ctx, t, env.db, "charlie")
|
||||||
|
|
||||||
_, _, err = env.svc.SendChannelMessage(
|
_, err = env.svc.SendChannelMessage(
|
||||||
ctx, sid3, "charlie",
|
ctx, sid3, 0, "charlie",
|
||||||
irc.CmdPrivmsg, "#chat", body, nil,
|
irc.CmdPrivmsg, "#chat", body, nil,
|
||||||
)
|
)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -914,14 +910,14 @@ func TestBroadcastQuit(t *testing.T) {
|
|||||||
sid2 := createSession(ctx, t, env.db, "bob")
|
sid2 := createSession(ctx, t, env.db, "bob")
|
||||||
|
|
||||||
_, err := env.svc.JoinChannel(
|
_, err := env.svc.JoinChannel(
|
||||||
ctx, sid1, "alice", "#room", "",
|
ctx, sid1, 0, "alice", "#room", "",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("join alice: %v", err)
|
t.Fatalf("join alice: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = env.svc.JoinChannel(
|
_, err = env.svc.JoinChannel(
|
||||||
ctx, sid2, "bob", "#room", "",
|
ctx, sid2, 0, "bob", "#room", "",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("join bob: %v", err)
|
t.Fatalf("join bob: %v", err)
|
||||||
@@ -963,7 +959,7 @@ func TestSendDirectMessageTargetQuits(t *testing.T) {
|
|||||||
body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson
|
body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson
|
||||||
|
|
||||||
_, err = env.svc.SendDirectMessage(
|
_, 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
|
var ircErr *service.IRCError
|
||||||
@@ -983,7 +979,7 @@ func TestSendChannelMessageChannelDeleted(t *testing.T) {
|
|||||||
|
|
||||||
sid := createSession(ctx, t, env.db, "alice")
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("join alice: %v", err)
|
t.Fatalf("join alice: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1000,8 +996,8 @@ func TestSendChannelMessageChannelDeleted(t *testing.T) {
|
|||||||
|
|
||||||
body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson
|
body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson
|
||||||
|
|
||||||
_, _, err = env.svc.SendChannelMessage(
|
_, err = env.svc.SendChannelMessage(
|
||||||
ctx, sid, "alice", irc.CmdPrivmsg, "#gone", body, nil,
|
ctx, sid, 0, "alice", irc.CmdPrivmsg, "#gone", body, nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
var ircErr *service.IRCError
|
var ircErr *service.IRCError
|
||||||
@@ -1019,14 +1015,14 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
|
|||||||
sid2 := createSession(ctx, t, env.db, "bob")
|
sid2 := createSession(ctx, t, env.db, "bob")
|
||||||
|
|
||||||
result, err := env.svc.JoinChannel(
|
result, err := env.svc.JoinChannel(
|
||||||
ctx, sid1, "alice", "#modchat", "",
|
ctx, sid1, 0, "alice", "#modchat", "",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("join alice: %v", err)
|
t.Fatalf("join alice: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = env.svc.JoinChannel(
|
_, err = env.svc.JoinChannel(
|
||||||
ctx, sid2, "bob", "#modchat", "",
|
ctx, sid2, 0, "bob", "#modchat", "",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("join bob: %v", err)
|
t.Fatalf("join bob: %v", err)
|
||||||
@@ -1039,8 +1035,8 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
|
|||||||
body, _ := json.Marshal([]string{"test"}) //nolint:errchkjson
|
body, _ := json.Marshal([]string{"test"}) //nolint:errchkjson
|
||||||
|
|
||||||
// Bob (non-op, non-voiced) should fail to send.
|
// Bob (non-op, non-voiced) should fail to send.
|
||||||
_, _, err = env.svc.SendChannelMessage(
|
_, err = env.svc.SendChannelMessage(
|
||||||
ctx, sid2, "bob",
|
ctx, sid2, 0, "bob",
|
||||||
irc.CmdPrivmsg, "#modchat", body, nil,
|
irc.CmdPrivmsg, "#modchat", body, nil,
|
||||||
)
|
)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -1048,8 +1044,8 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Alice (operator) should succeed.
|
// Alice (operator) should succeed.
|
||||||
_, _, err = env.svc.SendChannelMessage(
|
_, err = env.svc.SendChannelMessage(
|
||||||
ctx, sid1, "alice",
|
ctx, sid1, 0, "alice",
|
||||||
irc.CmdPrivmsg, "#modchat", body, nil,
|
irc.CmdPrivmsg, "#modchat", body, nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1074,7 +1070,7 @@ func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) {
|
|||||||
refuse func(ctx context.Context, env *testEnv, chID, bobSID int64) error
|
refuse func(ctx context.Context, env *testEnv, chID, bobSID int64) error
|
||||||
}{
|
}{
|
||||||
{"not a member", func(ctx context.Context, env *testEnv, _, 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 {
|
{"banned", func(ctx context.Context, env *testEnv, chID, _ int64) error {
|
||||||
return env.db.AddChannelBan(ctx, chID, "bob!*@*", "alice")
|
return env.db.AddChannelBan(ctx, chID, "bob!*@*", "alice")
|
||||||
@@ -1094,12 +1090,12 @@ func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) {
|
|||||||
aliceSID := createSession(ctx, t, env.db, "alice")
|
aliceSID := createSession(ctx, t, env.db, "alice")
|
||||||
bobSID := createSession(ctx, t, env.db, "bob")
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("join alice: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("join bob: %v", err)
|
t.Fatalf("join bob: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1119,8 +1115,8 @@ func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) {
|
|||||||
)
|
)
|
||||||
meta, _ := json.Marshal(map[string]string{"hashcash": stamp})
|
meta, _ := json.Marshal(map[string]string{"hashcash": stamp})
|
||||||
|
|
||||||
_, _, err = env.svc.SendChannelMessage(
|
_, err = env.svc.SendChannelMessage(
|
||||||
ctx, bobSID, "bob", irc.CmdPrivmsg, channel, body, meta,
|
ctx, bobSID, 0, "bob", irc.CmdPrivmsg, channel, body, meta,
|
||||||
)
|
)
|
||||||
|
|
||||||
var ircErr *service.IRCError
|
var ircErr *service.IRCError
|
||||||
@@ -1129,8 +1125,8 @@ func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) {
|
|||||||
t.Fatalf("bob: want ERR_CANNOTSENDTOCHAN, got %v", err)
|
t.Fatalf("bob: want ERR_CANNOTSENDTOCHAN, got %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _, err = env.svc.SendChannelMessage(
|
_, err = env.svc.SendChannelMessage(
|
||||||
ctx, aliceSID, "alice", irc.CmdPrivmsg, channel, body, meta,
|
ctx, aliceSID, 0, "alice", irc.CmdPrivmsg, channel, body, meta,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("alice with the same stamp: %v", err)
|
t.Fatalf("alice with the same stamp: %v", err)
|
||||||
|
|||||||
Reference in New Issue
Block a user