Author SHA1 Message Date
sneak 48f1bb2ce9 Relay INVITE to IRC listener users as an INVITE line (closes #147)
check / check (push) Canceled after 0s
An invite for an IRC listener user arrived as a server NOTICE that did
not name the inviter, so no IRC client treated it as an invite. The
relay now sends it as an INVITE line from the inviter, with the channel
taken from the message body, and sends nothing when the body names no
channel. The IRC listener now queues the INVITE with the channel as its
body, as the HTTP API already did, so an invite from either transport
has the same shape. The README's IRC listener section says how an
invite arrives.

Model: opus-5-5
2026-10-21 05:03:59 +00:00
27 changed files with 3647 additions and 3177 deletions
-4
View File
@@ -61,10 +61,6 @@ linters:
desc: >-
Test-support code belongs in test files and in packages whose
directory name ends in test, not in the shipped binary.
- pkg: sneak.berlin/go/neoirc/internal/dbtest
desc: >-
Test-support code belongs in test files and in packages whose
directory name ends in test, not in the shipped binary.
# Only decisions already recorded in the Go package defaults are
# listed here. Every entry matches the module path exactly.
gomodguard_v2:
+3173 -1560
View File
File diff suppressed because it is too large Load Diff
+3 -28
View File
@@ -25,10 +25,7 @@ const (
httpErrThreshold = 400
)
var (
errHTTP = errors.New("HTTP error")
errJoinRefused = errors.New("the server refused the join")
)
var errHTTP = errors.New("HTTP error")
// Client wraps HTTP calls to the neoirc server API.
type Client struct {
@@ -209,35 +206,13 @@ func (client *Client) PollMessages(
}, nil
}
// JoinChannel joins a channel. It returns an error unless the
// server answers that the session joined. When the server
// refuses the join, it sends the reason to the message queue as
// an error numeric.
// JoinChannel joins a channel.
func (client *Client) JoinChannel(channel string) error {
data, err := client.do(
http.MethodPost, "/api/v1/messages",
return client.SendMessage(
&Message{ //nolint:exhaustruct // only command+to needed
Command: irc.CmdJoin, To: channel,
},
)
if err != nil {
return err
}
var resp struct {
Status string `json:"status"`
}
err = json.Unmarshal(data, &resp)
if err != nil {
return fmt.Errorf("decode join: %w", err)
}
if resp.Status != "joined" {
return errJoinRefused
}
return nil
}
// PartChannel leaves a channel.
+46 -22
View File
@@ -96,7 +96,20 @@ func (a *App) handleInput(text string) {
a.ui.AddStatus(
"[red]Send error: " + err.Error(),
)
return
}
timestamp := time.Now().Format(timeFormat)
a.mu.Lock()
nick := a.nick
a.mu.Unlock()
a.ui.AddLine(target, fmt.Sprintf(
"[gray]%s [green]<%s>[white] %s",
timestamp, nick, text,
))
}
func (a *App) handleCommand(text string) {
@@ -288,6 +301,9 @@ func (a *App) cmdJoin(channel string) {
a.mu.Unlock()
a.ui.SwitchToBuffer(channel)
a.ui.AddLine(channel,
"[yellow]*** Joined "+channel,
)
a.ui.SetStatus(nick, channel, "connected")
}
@@ -322,6 +338,10 @@ func (a *App) cmdPart(channel string) {
return
}
a.ui.AddLine(channel,
"[yellow]*** Left "+channel,
)
a.mu.Lock()
if a.target == channel {
a.target = ""
@@ -348,6 +368,7 @@ func (a *App) cmdMsg(args string) {
a.mu.Lock()
connected := a.connected
nick := a.nick
a.mu.Unlock()
if !connected {
@@ -365,7 +386,16 @@ func (a *App) cmdMsg(args string) {
a.ui.AddStatus(fmt.Sprintf(
"[red]Send failed: %v", err,
))
return
}
timestamp := time.Now().Format(timeFormat)
a.ui.AddLine(target, fmt.Sprintf(
"[gray]%s [green]<%s>[white] %s",
timestamp, nick, text,
))
}
func (a *App) cmdQuery(nick string) {
@@ -727,7 +757,7 @@ func (a *App) handleServerMessage(msg *api.Message) {
case irc.CmdJoin:
a.handleJoinEvent(msg, timestamp)
case irc.CmdPart:
a.handlePartEvent(msg, timestamp, myNick)
a.handlePartEvent(msg, timestamp)
case irc.CmdQuit:
a.handleQuitEvent(msg, timestamp)
case irc.CmdNick:
@@ -755,10 +785,12 @@ func (a *App) handlePrivmsgEvent(
lines := msg.BodyLines()
text := strings.Join(lines, " ")
// A direct message shows in the buffer of the other user:
// the recipient of one the user sent, else the sender.
if msg.From == myNick {
return
}
target := msg.To
if !strings.HasPrefix(target, "#") && msg.From != myNick {
if !strings.HasPrefix(target, "#") {
target = msg.From
}
@@ -781,11 +813,8 @@ func (a *App) handleJoinEvent(
))
}
// handlePartEvent shows a part in the channel's buffer. The
// user's own part does not mark the channel unread: the user
// has just left it.
func (a *App) handlePartEvent(
msg *api.Message, timestamp, myNick string,
msg *api.Message, timestamp string,
) {
if msg.To == "" {
return
@@ -794,22 +823,17 @@ func (a *App) handlePartEvent(
lines := msg.BodyLines()
reason := strings.Join(lines, " ")
line := fmt.Sprintf(
"[gray]%s [yellow]*** %s has left %s",
timestamp, msg.From, msg.To,
)
if reason != "" {
line += " (" + reason + ")"
a.ui.AddLine(msg.To, fmt.Sprintf(
"[gray]%s [yellow]*** %s has left %s (%s)",
timestamp, msg.From, msg.To, reason,
))
} else {
a.ui.AddLine(msg.To, fmt.Sprintf(
"[gray]%s [yellow]*** %s has left %s",
timestamp, msg.From, msg.To,
))
}
if msg.From == myNick {
a.ui.AddLineAsRead(msg.To, line)
return
}
a.ui.AddLine(msg.To, line)
}
func (a *App) handleQuitEvent(
-286
View File
@@ -1,23 +1,9 @@
package cli
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/gdamore/tcell/v2"
api "sneak.berlin/go/neoirc/internal/cli/api"
"sneak.berlin/go/neoirc/pkg/irc"
)
// The user's nick, a channel it is in, another user's nick and
// a message text, for the tests below.
const (
testNick = "erin"
testChannel = "#chan"
testOtherNick = "bob"
testText = "hello"
)
func TestMemberPrefix(t *testing.T) {
@@ -63,275 +49,3 @@ func TestMemberPrefix(t *testing.T) {
})
}
}
// TestPartEvent checks that a PART event adds a line to the
// channel's buffer and marks it unread, except that the
// user's own part does not mark it unread.
func TestPartEvent(t *testing.T) {
t.Parallel()
tests := []struct {
name string
from string
wantUnread int
}{
{name: "own part", from: testNick, wantUnread: 0},
{name: "other member", from: testOtherNick, wantUnread: 1},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
app := &App{ui: NewUI(), nick: testNick}
runUI(t, app.ui)
app.handleServerMessage(&api.Message{
Command: irc.CmdPart, From: test.from, To: testChannel,
})
lines, unread := bufferState(app.ui, testChannel)
if lines != 1 || unread != test.wantUnread {
t.Fatalf("got %d lines and %d unread, want 1 and %d",
lines, unread, test.wantUnread)
}
})
}
}
// TestOwnCommandShownOnce checks that a message, join or
// part made from neoirc-cli shows as one line in the
// channel's buffer once the server's event for it arrives,
// and does not mark the channel unread.
func TestOwnCommandShownOnce(t *testing.T) {
t.Parallel()
tests := []struct {
name string
send func(app *App)
event api.Message
}{
{
name: "message",
send: func(app *App) { app.handleInput(testText) },
event: api.Message{
Command: irc.CmdPrivmsg, From: testNick, To: testChannel,
Body: []string{testText},
},
},
{
name: "join",
send: func(app *App) { app.cmdJoin(testChannel) },
event: api.Message{
Command: irc.CmdJoin, From: testNick, To: testChannel,
},
},
{
name: "part",
send: func(app *App) { app.cmdPart(testChannel) },
event: api.Message{
Command: irc.CmdPart, From: testNick, To: testChannel,
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
// The server accepts every command. Only the response
// to a JOIN is read, and it says the session joined.
server := newServer(t, `{"status": "joined"}`)
app := &App{
ui: NewUI(),
client: api.NewClient(server.URL),
nick: testNick,
target: testChannel,
connected: true,
}
runUI(t, app.ui)
app.ui.SwitchToBuffer(testChannel)
test.send(app)
app.handleServerMessage(&test.event)
lines, unread := bufferState(app.ui, testChannel)
if lines != 1 || unread != 0 {
t.Fatalf("got %d lines and %d unread, want 1 and 0",
lines, unread)
}
})
}
}
// TestJoinResponse checks that /join switches to the channel's
// buffer and makes it the target only when the server answers
// that the session joined.
func TestJoinResponse(t *testing.T) {
t.Parallel()
tests := []struct {
name string
response string
// The buffer shown and the target after the join.
want string
}{
{
name: "joined",
response: `{"status": "joined", "channel": "#chan"}`,
want: testChannel,
},
{
name: "refused",
response: `{"status": "error"}`,
want: testOtherNick,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
app := &App{
ui: NewUI(),
client: api.NewClient(newServer(t, test.response).URL),
nick: testNick,
target: testOtherNick,
connected: true,
}
runUI(t, app.ui)
app.ui.SwitchToBuffer(testOtherNick)
app.cmdJoin(testChannel)
var shown string
app.ui.app.QueueUpdate(func() {
shown = app.ui.buffers[app.ui.currentBuffer].Name
})
if shown != test.want || app.target != test.want {
t.Fatalf("got buffer %s and target %s, want %s",
shown, app.target, test.want)
}
})
}
}
// TestOwnEventFromOtherClient checks that a message, notice,
// join or part that the user's session makes from another
// client shows as one line in the buffer it belongs to.
func TestOwnEventFromOtherClient(t *testing.T) {
t.Parallel()
tests := []struct {
name string
event api.Message
buffer string
}{
{
name: "channel message",
event: api.Message{
Command: irc.CmdPrivmsg, From: testNick, To: testChannel,
Body: []string{testText},
},
buffer: testChannel,
},
{
name: "direct message",
event: api.Message{
Command: irc.CmdPrivmsg, From: testNick, To: testOtherNick,
Body: []string{testText},
},
buffer: testOtherNick,
},
{
name: "notice",
event: api.Message{
Command: irc.CmdNotice, From: testNick, To: testChannel,
Body: []string{testText},
},
buffer: "(status)",
},
{
name: "join",
event: api.Message{
Command: irc.CmdJoin, From: testNick, To: testChannel,
},
buffer: testChannel,
},
{
name: "part",
event: api.Message{
Command: irc.CmdPart, From: testNick, To: testChannel,
},
buffer: testChannel,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
app := &App{ui: NewUI(), nick: testNick}
runUI(t, app.ui)
app.handleServerMessage(&test.event)
lines, _ := bufferState(app.ui, test.buffer)
if lines != 1 {
t.Fatalf("got %d lines in %s, want 1", lines, test.buffer)
}
})
}
}
// bufferState returns the number of lines in the named
// buffer and how many of them are unread.
func bufferState(ui *UI, name string) (int, int) {
var lines, unread int
ui.app.QueueUpdate(func() {
buf := ui.getOrCreateBuffer(name)
lines = len(buf.Lines)
unread = buf.Unread
})
return lines, unread
}
// newServer starts a server that answers every request with
// body until the test ends.
func newServer(t *testing.T, body string) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(
func(writer http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(writer, body)
},
))
t.Cleanup(server.Close)
return server
}
// 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)
}
})
}
-13
View File
@@ -95,19 +95,6 @@ func (ui *UI) AddLine(bufferName, line string) {
})
}
// AddLineAsRead adds a line to the specified buffer, as
// AddLine does, but does not mark the buffer unread.
func (ui *UI) AddLineAsRead(bufferName, line string) {
ui.app.QueueUpdateDraw(func() {
buf := ui.getOrCreateBuffer(bufferName)
buf.Lines = append(buf.Lines, line)
if ui.buffers[ui.currentBuffer] == buf {
_, _ = fmt.Fprintln(ui.messages, line)
}
})
}
// AddStatus adds a line to the status buffer.
func (ui *UI) AddStatus(line string) {
ts := time.Now().Format("15:04")
+7 -33
View File
@@ -5,11 +5,9 @@ import (
"context"
"database/sql"
"embed"
"errors"
"fmt"
"io/fs"
"log/slog"
"net/url"
"sort"
"strconv"
"strings"
@@ -24,10 +22,6 @@ import (
const minMigrationParts = 2
var errForeignKeysInDBURL = errors.New(
"DBURL must not set foreign_keys, which the server always turns on",
)
// SchemaFiles contains embedded SQL migration files.
//
//go:embed schema/*.sql
@@ -96,33 +90,6 @@ func (database *Database) connect(ctx context.Context) error {
dbURL = config.DefaultDBURL
}
// SQLite keeps the foreign keys setting per connection. The driver
// runs each _pragma parameter on every connection it opens. So the
// one added below also covers the connection the pool opens after
// a cancelled query breaks the old one. A foreign_keys pragma in
// DBURL could undo it, because the driver sorts the pragmas.
_, query, hasQuery := strings.Cut(dbURL, "?")
urlParams, err := url.ParseQuery(query)
if err != nil {
return fmt.Errorf("parse DBURL: %w", err)
}
for _, pragma := range urlParams["_pragma"] {
if strings.Contains(strings.ToLower(pragma), "foreign_keys") {
return fmt.Errorf(
"%w: _pragma=%s", errForeignKeysInDBURL, pragma,
)
}
}
separator := "?"
if hasQuery {
separator = "&"
}
dbURL += separator + "_pragma=foreign_keys(1)"
database.log.Info(
"connecting to database", "url", dbURL,
)
@@ -142,6 +109,13 @@ func (database *Database) connect(ctx context.Context) error {
database.conn = conn
database.log.Info("database connected")
_, err = database.conn.ExecContext(
ctx, "PRAGMA foreign_keys = ON",
)
if err != nil {
return fmt.Errorf("enable foreign keys: %w", err)
}
return database.runMigrations(ctx)
}
-203
View File
@@ -1,34 +1,19 @@
package db_test
import (
"context"
"errors"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/dbtest"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/logger"
)
const wantBusyTimeoutMS = 5000
const (
// endlessQuery counts the rows of a recursive query that never
// stops, so it runs until its context ends.
endlessQuery = `WITH RECURSIVE n(x) AS
(SELECT 1 UNION ALL SELECT x + 1 FROM n)
SELECT COUNT(*) FROM n`
endlessQueryTimeout = 100 * time.Millisecond
)
// TestDefaultDBURLSetsWALAndBusyTimeout opens a file database
// with the default URL's parameters the way the server does,
// and checks the journal mode and busy timeout it ends up with.
@@ -88,191 +73,3 @@ func TestDefaultDBURLSetsWALAndBusyTimeout(t *testing.T) {
)
}
}
// TestForeignKeysOnAfterCancelledQuery cancels a query while it
// runs, which makes the pool close its only connection and open a
// new one. A TEMP table lasts only as long as its connection, so
// its absence shows the connection was replaced. The test then
// checks that the file database keeps its tables and that the new
// connection has foreign keys on.
func TestForeignKeysOnAfterCancelledQuery(t *testing.T) {
t.Parallel()
_, defaultParams, _ := strings.Cut(config.DefaultDBURL, "?")
urlParams := map[string]string{
"default parameters": "?" + defaultParams,
"no parameters": "",
}
for name, params := range urlParams {
t.Run(name, func(t *testing.T) {
t.Parallel()
dbURL := "file://" +
filepath.Join(t.TempDir(), "state.db") + params
database, err := startDatabase(t, dbURL)
if err != nil {
t.Fatal(err)
}
_, err = database.GetDB().ExecContext(
t.Context(), "CREATE TEMP TABLE first_connection (x)",
)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(
t.Context(), endlessQueryTimeout,
)
defer cancel()
_, err = database.GetDB().ExecContext(ctx, endlessQuery)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("endless query: err = %v", err)
}
tempTables := queryInt(
t, database, "SELECT COUNT(*) FROM sqlite_temp_schema",
)
if tempTables != 0 {
t.Fatal("the pool kept its first connection")
}
migrations := queryInt(
t, database, "SELECT COUNT(*) FROM schema_migrations",
)
if migrations == 0 {
t.Error("schema_migrations is empty")
}
foreignKeys := queryInt(t, database, "PRAGMA foreign_keys")
if foreignKeys != 1 {
t.Errorf("foreign_keys = %d, want 1", foreignKeys)
}
})
}
}
// TestNewURLKeepsDataAfterCancelledQuery cancels a query on a test
// database while it runs, which makes the pool close its only
// connection and open a new one. A TEMP table lasts only as long as
// its connection, so its absence shows the connection was replaced.
// The database must still have its tables and rows.
func TestNewURLKeepsDataAfterCancelledQuery(t *testing.T) {
t.Parallel()
database, err := startDatabase(t, dbtest.NewURL(t))
if err != nil {
t.Fatal(err)
}
_, err = database.GetOrCreateChannel(t.Context(), "#test")
if err != nil {
t.Fatal(err)
}
_, err = database.GetDB().ExecContext(
t.Context(), "CREATE TEMP TABLE first_connection (x)",
)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(t.Context(), endlessQueryTimeout)
defer cancel()
_, err = database.GetDB().ExecContext(ctx, endlessQuery)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("endless query: err = %v", err)
}
tempTables := queryInt(
t, database, "SELECT COUNT(*) FROM sqlite_temp_schema",
)
if tempTables != 0 {
t.Fatal("the pool kept its first connection")
}
channels := queryInt(t, database, "SELECT COUNT(*) FROM channels")
if channels != 1 {
t.Errorf("channels = %d, want 1", channels)
}
}
// TestDBURLSettingForeignKeysFailsStartup checks that the database
// refuses to start when DBURL sets foreign_keys itself, however it
// is spelled.
func TestDBURLSettingForeignKeysFailsStartup(t *testing.T) {
t.Parallel()
pragmas := []string{
"foreign_keys(0)",
"foreign_keys(off)",
"foreign_keys=0",
"FOREIGN_KEYS(FALSE)",
" Foreign_Keys = 1",
}
for _, pragma := range pragmas {
t.Run(pragma, func(t *testing.T) {
t.Parallel()
dbURL := "file://" +
filepath.Join(t.TempDir(), "state.db") +
"?_pragma=" + url.QueryEscape(pragma)
_, err := startDatabase(t, dbURL)
if err == nil {
t.Fatal("the database started")
}
if !strings.Contains(err.Error(), "DBURL") {
t.Errorf("error %q does not name DBURL", err)
}
})
}
}
// startDatabase starts a Database for dbURL the way the server does.
// It returns the error that stopped it from starting, if any.
func startDatabase(t *testing.T, dbURL string) (*db.Database, error) {
t.Helper()
lifecycle := fxtest.NewLifecycle(t)
log, err := logger.New(lifecycle, logger.Params{
Globals: &globals.Globals{},
})
if err != nil {
t.Fatal(err)
}
database, err := db.New(lifecycle, db.Params{
Logger: log,
Config: &config.Config{DBURL: dbURL},
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(lifecycle.RequireStop)
return database, lifecycle.Start(t.Context())
}
// queryInt runs a query that returns a single number.
func queryInt(t *testing.T, database *db.Database, query string) int {
t.Helper()
var result int
err := database.GetDB().QueryRowContext(t.Context(), query).Scan(&result)
if err != nil {
t.Fatal(err)
}
return result
}
+12 -7
View File
@@ -5,16 +5,21 @@ import (
"database/sql"
"fmt"
"log/slog"
"testing"
"sneak.berlin/go/neoirc/internal/dbtest"
"sync/atomic"
)
// NewTestDatabase creates an in-memory database for testing.
func NewTestDatabase(tb testing.TB) (*Database, error) {
tb.Helper()
//nolint:gochecknoglobals // test counter
var testDBCounter atomic.Int64
dsn := dbtest.NewURL(tb) + "&_pragma=foreign_keys(1)"
// NewTestDatabase creates an in-memory database for testing.
func NewTestDatabase() (*Database, error) {
counter := testDBCounter.Add(1)
dsn := fmt.Sprintf(
"file:testdb%d?mode=memory"+
"&cache=shared&_pragma=foreign_keys(1)",
counter,
)
conn, err := sql.Open("sqlite", dsn)
if err != nil {
+8 -30
View File
@@ -264,25 +264,6 @@ func (database *Database) GetSessionByNick(
return sessionID, nil
}
// GetSessionNick returns the nick a session holds.
func (database *Database) GetSessionNick(
ctx context.Context,
sessionID int64,
) (string, error) {
var nick string
err := database.conn.QueryRowContext(
ctx,
"SELECT nick FROM sessions WHERE id = ?",
sessionID,
).Scan(&nick)
if err != nil {
return "", fmt.Errorf("get session nick: %w", err)
}
return nick, nil
}
// SessionHostInfo holds the username, hostname, and IP
// for a session.
type SessionHostInfo struct {
@@ -1227,12 +1208,11 @@ func insertMessageInTx(
return dbID, msgUUID, nil
}
// 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.
// EnqueueToSession adds a message to all clients of a
// session's queues.
func (database *Database) EnqueueToSession(
ctx context.Context,
sessionID, messageID, skipClientID int64,
sessionID, messageID int64,
) error {
transaction, err := database.conn.BeginTx(ctx, nil)
if err != nil {
@@ -1241,9 +1221,7 @@ func (database *Database) EnqueueToSession(
defer func() { _ = transaction.Rollback() }()
err = enqueueToSessionInTx(
ctx, transaction, sessionID, messageID, skipClientID,
)
err = enqueueToSessionInTx(ctx, transaction, sessionID, messageID)
if err != nil {
return err
}
@@ -1261,15 +1239,15 @@ func (database *Database) EnqueueToSession(
func enqueueToSessionInTx(
ctx context.Context,
transaction *sql.Tx,
sessionID, messageID, skipClientID int64,
sessionID, messageID 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 = ? AND c.id != ?`,
messageID, time.Now(), sessionID, skipClientID)
WHERE c.session_id = ?`,
messageID, time.Now(), sessionID)
if err != nil {
return fmt.Errorf(
"enqueue to session: %w", err,
@@ -1695,7 +1673,7 @@ func quitSessionInTx(
for _, peerID := range peerIDs {
err = enqueueToSessionInTx(
ctx, transaction, peerID, messageID, 0,
ctx, transaction, peerID, messageID,
)
if err != nil {
return nil, err
+2 -2
View File
@@ -13,7 +13,7 @@ import (
func setupTestDB(t *testing.T) *db.Database {
t.Helper()
database, err := db.NewTestDatabase(t)
database, err := db.NewTestDatabase()
if err != nil {
t.Fatal(err)
}
@@ -675,7 +675,7 @@ func TestPollMessages(t *testing.T) {
t.Fatal(err)
}
err = database.EnqueueToSession(ctx, sid, dbID, 0)
err = database.EnqueueToSession(ctx, sid, dbID)
if err != nil {
t.Fatal(err)
}
-39
View File
@@ -1,39 +0,0 @@
// Package dbtest gives each test a database of its own. Only tests
// may import it.
package dbtest
import (
"crypto/rand"
"database/sql"
"testing"
_ "modernc.org/sqlite" // registers the sqlite driver
)
// NewURL returns the URL of a new, empty in-memory database for one
// test. SQLite deletes an in-memory database when its last connection
// closes. A pool closes a connection when a query on it is cancelled
// while it runs. So this function holds a connection of its own open
// until the test ends.
func NewURL(tb testing.TB) string {
tb.Helper()
// A random name, so that no other test can share the database.
dbURL := "file:" + rand.Text() + "?mode=memory&cache=shared"
pool, err := sql.Open("sqlite", dbURL)
if err != nil {
tb.Fatalf("open test database: %v", err)
}
tb.Cleanup(func() { _ = pool.Close() })
conn, err := pool.Conn(tb.Context())
if err != nil {
tb.Fatalf("connect to test database: %v", err)
}
tb.Cleanup(func() { _ = conn.Close() })
return dbURL
}
+19 -14
View File
@@ -1257,11 +1257,9 @@ func (hdlr *Handlers) handleChannelMsg(
) {
ctx := request.Context()
// 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,
// Delegate validation + fan-out to service layer.
dbID, uuid, err := hdlr.svc.SendChannelMessage(
ctx, sessionID, nick,
command, target, body, meta,
)
if hdlr.handleServiceError(
@@ -1271,6 +1269,13 @@ 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)
@@ -1285,7 +1290,7 @@ func (hdlr *Handlers) handleDirectMsg(
meta json.RawMessage,
) {
result, err := hdlr.svc.SendDirectMessage(
request.Context(), sessionID, 0, nick,
request.Context(), sessionID, nick,
command, target, body, meta,
)
if hdlr.handleServiceError(
@@ -1356,7 +1361,7 @@ func (hdlr *Handlers) executeJoin(
nick, channel, suppliedKey string,
) {
result, err := hdlr.svc.JoinChannel(
request.Context(), sessionID, 0, nick,
request.Context(), sessionID, nick,
channel, suppliedKey,
)
if hdlr.handleServiceError(
@@ -1540,7 +1545,7 @@ func (hdlr *Handlers) handlePart(
}
err := hdlr.svc.PartChannel(
request.Context(), sessionID, 0,
request.Context(), sessionID,
nick, channel, reason,
)
if hdlr.handleServiceError(
@@ -1611,7 +1616,7 @@ func (hdlr *Handlers) executeNickChange(
nick, newNick string,
) {
err := hdlr.svc.ChangeNick(
request.Context(), sessionID, 0, nick, newNick,
request.Context(), sessionID, nick, newNick,
)
if hdlr.handleServiceError(
writer, request,
@@ -1666,7 +1671,7 @@ func (hdlr *Handlers) handleTopic(
topic := strings.Join(lines, " ")
err := hdlr.svc.SetTopic(
request.Context(), sessionID, 0,
request.Context(), sessionID,
nick, channel, topic,
)
if hdlr.handleServiceError(
@@ -2152,7 +2157,7 @@ func (hdlr *Handlers) applyUserMode(
modeText := modeArgs[0] + " " + targetNick
hdlr.svc.BroadcastMode(
request.Context(), 0, nick, channel, chID,
request.Context(), nick, channel, chID,
modeText,
)
@@ -2198,7 +2203,7 @@ func (hdlr *Handlers) setChannelFlag(
}
hdlr.svc.BroadcastMode(
request.Context(), 0, nick, channel, chID,
request.Context(), nick, channel, chID,
modeStr,
)
@@ -3645,7 +3650,7 @@ func (hdlr *Handlers) handleKick(
}
err := hdlr.svc.KickUser(
request.Context(), sessionID, 0, nick,
request.Context(), sessionID, nick,
channel, targetNick, reason,
)
if hdlr.handleServiceError(
@@ -3718,7 +3723,7 @@ func (hdlr *Handlers) fanOut(
for _, sid := range sessionIDs {
enqErr := hdlr.params.Database.EnqueueToSession(
request.Context(), sid, dbID, 0,
request.Context(), sid, dbID,
)
if enqErr != nil {
hdlr.log.Error("enqueue failed",
File diff suppressed because it is too large Load Diff
+1 -10
View File
@@ -1,3 +1,4 @@
//nolint:paralleltest // global viper, as in api_test.go
package handlers_test
import (
@@ -71,8 +72,6 @@ func numericBody(
}
func TestUserhost(t *testing.T) {
t.Parallel()
tserver := newTestServer(t)
token := tserver.createSession("alice")
@@ -110,8 +109,6 @@ func TestUserhost(t *testing.T) {
}
func TestVersionAdminInfoTime(t *testing.T) {
t.Parallel()
tserver := newTestServer(t)
token := tserver.createSession("frank")
@@ -150,8 +147,6 @@ func TestVersionAdminInfoTime(t *testing.T) {
}
func TestKill(t *testing.T) {
t.Parallel()
tserver := newTestServerWithOper(t)
victimToken := tserver.createSession("victim")
@@ -218,8 +213,6 @@ func TestKill(t *testing.T) {
}
func TestWallops(t *testing.T) {
t.Parallel()
tserver := newTestServerWithOper(t)
receiverToken := tserver.createSession("receiver")
@@ -265,8 +258,6 @@ func TestWallops(t *testing.T) {
}
func TestUserMode(t *testing.T) {
t.Parallel()
const (
nick = "alice"
other = "other"
+17 -18
View File
@@ -81,10 +81,10 @@ func (c *Conn) handleNick(
}
oldMask := c.hostmask()
oldNick := c.currentNick()
oldNick := c.nick
err := c.svc.ChangeNick(
ctx, c.sessionID, c.clientID, oldNick, newNick,
ctx, c.sessionID, oldNick, newNick,
)
if err != nil {
c.sendIRCError(err)
@@ -92,10 +92,9 @@ func (c *Conn) handleNick(
return
}
// Take the nick the session holds, not newNick: another
// client of the session may have changed it again since
// ChangeNick stored newNick.
c.loadSessionNick(ctx)
c.mu.Lock()
c.nick = newNick
c.mu.Unlock()
// Echo NICK change to the client on wire.
c.send(FormatMessage(oldMask, "NICK", newNick))
@@ -129,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.clientID, c.currentNick(),
_, _, err := c.svc.SendChannelMessage(
ctx, c.sessionID, c.nick,
msg.Command, target, body, nil,
)
if err != nil {
@@ -138,7 +137,7 @@ func (c *Conn) handlePrivmsg(
}
} else {
result, err := c.svc.SendDirectMessage(
ctx, c.sessionID, c.clientID, c.currentNick(),
ctx, c.sessionID, c.nick,
msg.Command, target, body, nil,
)
if err != nil {
@@ -200,7 +199,7 @@ func (c *Conn) joinChannel(
channel, key string,
) {
result, err := c.svc.JoinChannel(
ctx, c.sessionID, c.clientID, c.currentNick(), channel, key,
ctx, c.sessionID, c.nick, channel, key,
)
if err != nil {
c.sendIRCError(err)
@@ -338,7 +337,7 @@ func (c *Conn) partChannel(
channel, reason string,
) {
err := c.svc.PartChannel(
ctx, c.sessionID, c.clientID, c.currentNick(), channel, reason,
ctx, c.sessionID, c.nick, channel, reason,
)
if err != nil {
c.sendIRCError(err)
@@ -402,7 +401,7 @@ func (c *Conn) handleTopic(
newTopic := msg.Params[1]
err := c.svc.SetTopic(
ctx, c.sessionID, c.clientID, c.currentNick(), channel, newTopic,
ctx, c.sessionID, c.nick, channel, newTopic,
)
if err != nil {
c.sendIRCError(err)
@@ -709,7 +708,7 @@ func (c *Conn) applyChannelModes(
c.send(FormatMessage(c.hostmask(), "MODE", params...))
c.svc.BroadcastMode(
ctx, c.clientID, c.currentNick(), channel, chID, modeReply,
ctx, c.nick, channel, chID, modeReply,
)
}
}
@@ -1186,7 +1185,7 @@ func (c *Conn) handleKick(
}
err := c.svc.KickUser(
ctx, c.sessionID, c.clientID, c.currentNick(),
ctx, c.sessionID, c.nick,
channel, targetNick, reason,
)
if err != nil {
@@ -1226,7 +1225,7 @@ func (c *Conn) handlePassPostReg(
return
}
c.sendFromServer("NOTICE", c.currentNick(),
c.sendFromServer("NOTICE", c.nick,
"Password set. You can reconnect using "+
"PASS <password> with your nick.",
)
@@ -1250,7 +1249,7 @@ func (c *Conn) handleInvite(
channel := msg.Params[1]
targetID, err := c.svc.InviteUser(
ctx, c.sessionID, c.currentNick(), targetNick, channel,
ctx, c.sessionID, c.nick, targetNick, channel,
)
if err != nil {
c.sendIRCError(err)
@@ -1267,8 +1266,8 @@ func (c *Conn) handleInvite(
body, _ := json.Marshal([]string{channel}) //nolint:errchkjson
_, _, _ = c.svc.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, "INVITE", c.currentNick(), targetNick, targetID, 0,
nil, body, nil, []int64{targetID}, 0,
ctx, "INVITE", c.nick, targetNick, targetID, 0,
nil, body, nil, []int64{targetID},
)
}
+4 -22
View File
@@ -313,22 +313,6 @@ func (c *Conn) currentNick() string {
return c.nick
}
// loadSessionNick sets c.nick to the nick the connection's
// session holds in the database, read by session ID. The
// connection's own NICK and the relay passing on a NICK line
// both call it, and it reads and sets the nick under c.mu, so
// whichever runs last leaves the connection on its session's
// nick. If the read fails, c.nick is left as it is.
func (c *Conn) loadSessionNick(ctx context.Context) {
c.mu.Lock()
defer c.mu.Unlock()
nick, err := c.database.GetSessionNick(ctx, c.sessionID)
if err == nil {
c.nick = nick
}
}
// send writes a formatted IRC line to the connection.
func (c *Conn) send(line string) {
c.sendWithin(writeTimeout, line)
@@ -361,7 +345,7 @@ func (c *Conn) sendNumeric(
code irc.IRCMessageType,
params ...string,
) {
nick := c.currentNick()
nick := c.nick
if nick == "" {
nick = "*"
}
@@ -385,11 +369,9 @@ func (c *Conn) sendFromServer(
// hostmask returns the client's full hostmask
// (nick!user@host).
func (c *Conn) hostmask() string {
nick := c.currentNick()
user := c.username
if user == "" {
user = nick
user = c.nick
}
host := c.hostname
@@ -397,7 +379,7 @@ func (c *Conn) hostmask() string {
host = c.remoteIP
}
return nick + "!" + user + "@" + host
return c.nick + "!" + user + "@" + host
}
// handleMessage dispatches a parsed IRC message using
@@ -675,7 +657,7 @@ func (c *Conn) checkPasswordLength(pw string) bool {
return true
}
c.sendFromServer("NOTICE", c.currentNick(),
c.sendFromServer("NOTICE", c.nick,
"Password must be 8 to 72 bytes",
)
-5
View File
@@ -71,11 +71,6 @@ 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.
+74 -15
View File
@@ -32,12 +32,8 @@ func (c *Conn) relayMessages(ctx context.Context) {
// still signals waitCh.
waitCh := c.brk.Wait(c.sessionID)
// Drain any available messages. The drain ignores the
// cancel that comes when the connection ends, because a
// query cancelled while it runs leaves the SQLite
// connection marked broken, and the pool closes it. The
// loop still stops on ctx.
delivered := c.drainQueue(context.WithoutCancel(ctx))
// Drain any available messages.
delivered := c.drainQueue(ctx)
if delivered {
c.brk.Remove(c.sessionID, waitCh)
@@ -96,7 +92,7 @@ func (c *Conn) drainQueue(ctx context.Context) bool {
//
//nolint:cyclop // dispatch table
func (c *Conn) deliverIRCMessage(
ctx context.Context,
_ context.Context,
msg *db.IRCMessage,
) {
command := msg.Command
@@ -124,7 +120,7 @@ func (c *Conn) deliverIRCMessage(
case command == irc.CmdPart:
c.deliverPart(msg, text)
case command == irc.CmdNick:
c.deliverNickChange(ctx, msg, text)
c.deliverNickChange(msg, text)
case command == irc.CmdQuit:
c.deliverQuitMsg(msg, text)
case command == irc.CmdTopic:
@@ -202,6 +198,15 @@ 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 + "@*"
@@ -214,6 +219,16 @@ 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
@@ -222,6 +237,14 @@ 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
@@ -234,17 +257,18 @@ func (c *Conn) deliverPart(msg *db.IRCMessage, text string) {
}
}
// deliverNickChange sends a NICK change notification. The
// change may be its own session's, made from another client,
// so the connection first takes the nick its session holds
// now. It does not go by the line's old nick: the connection
// may have taken that nick after another user gave it up.
// deliverNickChange sends a NICK change notification.
func (c *Conn) deliverNickChange(
ctx context.Context,
msg *db.IRCMessage,
newNick string,
) {
c.loadSessionNick(ctx)
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
if msg.From == nick {
return
}
prefix := msg.From + "!" + msg.From + "@*"
@@ -256,6 +280,14 @@ 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 != "" {
@@ -272,6 +304,15 @@ func (c *Conn) deliverTopicChange(
msg *db.IRCMessage,
text string,
) {
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
// handleTopic already echoed our own topic change.
if msg.From == nick {
return
}
prefix := msg.From + "!" + msg.From + "@*"
channel := msg.To
@@ -283,6 +324,15 @@ func (c *Conn) deliverKickMsg(
msg *db.IRCMessage,
text string,
) {
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
// handleKick already echoed our own kick.
if msg.From == nick {
return
}
prefix := msg.From + "!" + msg.From + "@*"
channel := msg.To
@@ -353,6 +403,15 @@ func (c *Conn) deliverMode(
return
}
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
// applyChannelModes already echoed our own mode change.
if msg.From == nick {
return
}
prefix := msg.From + "!" + msg.From + "@*"
params := slices.Concat(
+1 -305
View File
@@ -1,7 +1,6 @@
package ircserver_test
import (
"context"
"encoding/json"
"slices"
"strings"
@@ -9,7 +8,6 @@ import (
"time"
"sneak.berlin/go/neoirc/internal/ircserver"
"sneak.berlin/go/neoirc/internal/service"
)
// queueMessage queues a message for the user with nick
@@ -41,7 +39,7 @@ func (env *testEnv) queueMessage(
t.Fatalf("get session of %s: %v", recipient, err)
}
err = env.database.EnqueueToSession(ctx, sessionID, msgID, 0)
err = env.database.EnqueueToSession(ctx, sessionID, msgID)
if err != nil {
t.Fatalf("enqueue: %v", err)
}
@@ -393,305 +391,3 @@ 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 sets up alice with aliceWithHTTPClient. 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()
ctx := t.Context()
env := newTestEnv(t)
alice, _, sessionID, httpClientID := aliceWithHTTPClient(t, env)
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
}
// aliceWithHTTPClient 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 returns her connection, bob's
// connection, her session and her HTTP API client.
func aliceWithHTTPClient(
t *testing.T, env *testEnv,
) (*testClient, *testClient, int64, int64) {
t.Helper()
const password = "alicepass"
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(
t.Context(), "alice", password, "127.0.0.1", "localhost",
)
if err != nil {
t.Fatalf("log in as alice: %v", err)
}
return alice, bob, sessionID, httpClientID
}
// TestRelayNickChangeFromHTTPAPI checks that once alice's nick
// is changed from her HTTP API client, her IRC listener
// connection uses the new nick: what she then sends from it
// reaches bob under the new nick.
func TestRelayNickChangeFromHTTPAPI(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice, bob, sessionID, _ := aliceWithHTTPClient(t, env)
bob.joinAndDrain("#other")
// As the HTTP API does.
err := env.srv.Service().ChangeNick(
t.Context(), sessionID, 0, "alice", "alice2",
)
if err != nil {
t.Fatalf("change nick over the HTTP API: %v", err)
}
// Her connection has taken the new nick once it has sent her
// the change.
const change = ":alice!alice@* NICK alice2"
alice.readUntil(func(l string) bool { return l == change })
bob.readUntil(func(l string) bool { return l == change })
for _, step := range []struct {
line, want string
}{
{"PRIVMSG #chan :hi there", ":alice2!alice2@* PRIVMSG #chan :hi there"},
{"JOIN #other", ":alice2!alice2@* JOIN #other"},
{"NICK alice3", ":alice2!alice2@* NICK alice3"},
} {
alice.send(step.line)
got := bob.readLine()
if got != step.want {
t.Errorf("%s: got %q, want %q", step.line, got, step.want)
}
}
}
// TestRelayNickChangeKeepsTakenNick checks that an IRC
// listener connection which takes a nick another user has
// given up keeps it when that user's change reaches it
// afterwards: what it then sends goes out under the nick its
// session holds.
func TestRelayNickChangeKeepsTakenNick(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
bob := env.dial(t)
bob.register("bob")
bob.sendAndExpect("NICK bob2", " NICK bob2")
alice := env.dial(t)
alice.register("alice")
alice.sendAndExpect("NICK bob", " NICK bob")
alice.joinAndDrain("#chan")
bob.joinAndDrain("#chan")
// Bob's change reaches alice only now, as it does when she
// takes his old nick before her connection has passed the
// change on. She holds bob now, so it is queued to bob.
env.queueMessage(t, "bob", "NICK", "bob", "", []string{"bob2"})
alice.readUntil(func(l string) bool {
return l == ":bob!bob@* NICK bob2"
})
alice.send("PRIVMSG #chan :hi there")
const want = ":bob!bob@* PRIVMSG #chan :hi there"
got := bob.readLine()
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
+28 -11
View File
@@ -3,6 +3,7 @@ package ircserver_test
import (
"bufio"
"context"
"crypto/rand"
"database/sql"
"errors"
"fmt"
@@ -19,7 +20,6 @@ import (
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/dbtest"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/ircserver"
"sneak.berlin/go/neoirc/internal/logger"
@@ -107,7 +107,14 @@ func newTestEnvWithResolver(
) *testEnv {
t.Helper()
conn, err := sql.Open("sqlite", dbtest.NewURL(t))
// A random name, so that no other test environment, not
// even an earlier run of the same test, can share it.
dsn := fmt.Sprintf(
"file:%s?mode=memory&cache=shared",
rand.Text(),
)
conn, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("open db: %v", err)
}
@@ -137,6 +144,20 @@ func newTestEnvWithResolver(
brk := broker.New()
var listenConfig net.ListenConfig
listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
addr := listener.Addr().String()
err = listener.Close()
if err != nil {
t.Fatalf("close listener: %v", err)
}
log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
@@ -146,9 +167,7 @@ func newTestEnvWithResolver(
log, cfg, database, brk, interval, deadline, resolve,
)
// Port 0 makes the system pick a free port. dial reads it
// back from the server's listener.
err = srv.Start("127.0.0.1:0")
err = srv.Start(addr)
if err != nil {
t.Fatalf("start irc server: %v", err)
}
@@ -626,6 +645,8 @@ func TestNewPingTimes(t *testing.T) {
func TestIdleCleanupKeepsActiveUser(t *testing.T) {
t.Parallel()
const idleTimeout = 500 * time.Millisecond
env := newTestEnv(t)
active := env.dial(t)
@@ -634,17 +655,13 @@ func TestIdleCleanupKeepsActiveUser(t *testing.T) {
idle := env.dial(t)
idle.register("idle")
// Both users were last seen before cutoff, and only active
// sends a command after it, so the result does not depend
// on how long the PONG takes to arrive.
cutoff := time.Now()
time.Sleep(idleTimeout)
active.sendAndExpect("PING :still here", "PONG")
// The idle cleanup in internal/handlers removes exactly
// the users this returns.
stale, err := env.database.GetStaleOrphanSessions(
t.Context(), cutoff,
t.Context(), time.Now().Add(-idleTimeout),
)
if err != nil {
t.Fatalf("get stale sessions: %v", err)
-6
View File
@@ -15,12 +15,6 @@ func NewTestSPAServer() *Server {
return srv
}
// Listener returns the listener the start hook bound for the
// HTTP server.
func (srv *Server) Listener() net.Listener {
return srv.listener
}
// ServeUntilShutdown runs serveUntilShutdown, which serves the
// HTTP server on listener, and returns when it does.
func (srv *Server) ServeUntilShutdown(listener net.Listener) {
-3
View File
@@ -50,7 +50,6 @@ type Server struct {
sentryEnabled bool
log *slog.Logger
httpServer *http.Server
listener net.Listener
router *chi.Mux
params Params
mw *middleware.Middleware
@@ -180,8 +179,6 @@ func (srv *Server) startHTTP(ctx context.Context) error {
return fmt.Errorf("http listen: %w", err)
}
srv.listener = listener
srv.httpServer = &http.Server{ //nolint:exhaustruct // optional fields
Addr: listenAddr,
ReadTimeout: httpReadTimeout,
+48 -15
View File
@@ -5,6 +5,7 @@
package server_test
import (
"crypto/rand"
"fmt"
"net"
"net/http"
@@ -18,7 +19,6 @@ import (
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/dbtest"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/handlers"
"sneak.berlin/go/neoirc/internal/healthcheck"
@@ -45,9 +45,10 @@ const (
// for a long poll the HTTP server is serving to finish, and
// that the poll gets its response.
func TestStopWaitsForRequestInProgress(t *testing.T) {
var srv *server.Server
port := freePort(t)
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
app, brk, database := startApp(t, fx.Populate(&srv))
app, brk, database := startApp(t, port)
sessionID, _, token, err := database.CreateSession(
t.Context(), "poller", "poller", "", "",
@@ -56,12 +57,9 @@ func TestStopWaitsForRequestInProgress(t *testing.T) {
t.Fatalf("create session: %v", err)
}
tcpAddr, ok := srv.Listener().Addr().(*net.TCPAddr)
if !ok {
t.Fatalf("listener address %v is not TCP", srv.Listener().Addr())
}
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(tcpAddr.Port))
waitUntil(t, "the HTTP server to listen", func() bool {
return canConnect(t, addr)
})
statuses := make(chan int, 1)
@@ -147,7 +145,7 @@ func TestStartFailsWhenHTTPPortIsTaken(t *testing.T) {
func TestServeFailureStopsApp(t *testing.T) {
var srv *server.Server
app, _, _ := startApp(t, fx.Populate(&srv))
app, _, _ := startApp(t, freePort(t), fx.Populate(&srv))
shutdowns := app.Wait()
@@ -170,7 +168,7 @@ func TestServeFailureStopsApp(t *testing.T) {
func TestServeAfterStopDoesNotStopApp(t *testing.T) {
var srv *server.Server
app, _, _ := startApp(t, fx.Populate(&srv))
app, _, _ := startApp(t, freePort(t), fx.Populate(&srv))
shutdowns := app.Wait()
@@ -188,15 +186,16 @@ func TestServeAfterStopDoesNotStopApp(t *testing.T) {
}
}
// startApp starts the app newApp builds, with the HTTP port
// set to 0, so the system gives the server a free port.
// startApp starts the app newApp builds, with the HTTP server
// listening on port.
func startApp(
t *testing.T,
port int,
options ...fx.Option,
) (*fxtest.App, *broker.Broker, *db.Database) {
t.Helper()
app, brk, database := newApp(t, 0, options...)
app, brk, database := newApp(t, port, options...)
app.RequireStart()
@@ -242,7 +241,10 @@ func newApp(
stats.New,
),
fx.Decorate(func(cfg *config.Config) *config.Config {
cfg.DBURL = dbtest.NewURL(t)
// A random name, so that no other test can share it.
cfg.DBURL = fmt.Sprintf(
"file:%s?mode=memory&cache=shared", rand.Text(),
)
cfg.Port = port
return cfg
@@ -255,6 +257,21 @@ func newApp(
return app, brk, database
}
// freePort returns a TCP port that nothing was listening on a
// moment ago.
func freePort(t *testing.T) int {
t.Helper()
listener := closedListener(t)
tcpAddr, ok := listener.Addr().(*net.TCPAddr)
if !ok {
t.Fatalf("listener address %v is not TCP", listener.Addr())
}
return tcpAddr.Port
}
// closedListener returns a TCP listener that has already been
// closed, so that accepting a connection on it fails.
func closedListener(t *testing.T) net.Listener {
@@ -277,6 +294,22 @@ func closedListener(t *testing.T) net.Listener {
return listener
}
// canConnect reports whether addr accepts a TCP connection.
func canConnect(t *testing.T, addr string) bool {
t.Helper()
var dialer net.Dialer
conn, err := dialer.DialContext(t.Context(), "tcp", addr)
if err != nil {
return false
}
_ = conn.Close()
return true
}
// waitUntil calls done every checkStep until it returns true,
// and fails the test if it does not within testTimeout.
func waitUntil(t *testing.T, what string, done func() bool) {
+68 -64
View File
@@ -312,7 +312,7 @@ func (s *Service) SendWallops(
_, _, err = s.FanOut(
ctx, irc.CmdWallops, nick, "*", 0, 0,
nil, body, nil, recipients, 0,
nil, body, nil, recipients,
)
if err != nil {
return fmt.Errorf("wallops: %w", err)
@@ -347,19 +347,12 @@ 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,
@@ -370,40 +363,58 @@ func (s *Service) FanOut(
}
for _, sid := range sessionIDs {
_ = s.db.EnqueueToSession(ctx, sid, dbID, skipClientID)
_ = s.db.EnqueueToSession(ctx, sid, dbID)
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,
// 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.
// 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).
func (s *Service) SendChannelMessage(
ctx context.Context,
sessionID, skipClientID int64,
sessionID int64,
nick, command, channel string,
body, meta json.RawMessage,
) (string, error) {
) (int64, string, error) {
noSuchChannel := &IRCError{
irc.ErrNoSuchChannel, []string{channel}, msgNoSuchChannel,
}
chID, err := s.db.GetChannelByName(ctx, channel)
if err != nil {
return "", noSuchChannel
return 0, "", noSuchChannel
}
isMember, _ := s.db.IsChannelMember(
ctx, chID, sessionID,
)
if !isMember {
return "", &IRCError{
return 0, "", &IRCError{
irc.ErrCannotSendToChan,
[]string{channel},
"Cannot send to channel",
@@ -415,7 +426,7 @@ func (s *Service) SendChannelMessage(
ctx, chID, sessionID,
)
if banErr == nil && isBanned {
return "", &IRCError{
return 0, "", &IRCError{
irc.ErrCannotSendToChan,
[]string{channel},
"Cannot send to channel (+b)",
@@ -432,7 +443,7 @@ func (s *Service) SendChannelMessage(
)
if !isOp && !isVoiced {
return "", &IRCError{
return 0, "", &IRCError{
irc.ErrCannotSendToChan,
[]string{channel},
"Cannot send to channel (+m)",
@@ -445,35 +456,35 @@ func (s *Service) SendChannelMessage(
ctx, chID, channel, body, meta,
)
if err != nil {
return "", err
return 0, "", err
}
}
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
recipients := excludeSession(memberIDs, sessionID)
_, uuid, fanErr := s.FanOut(
dbID, uuid, fanErr := s.FanOut(
ctx, command, nick, channel, 0, chID,
nil, body, meta, memberIDs, skipClientID,
nil, body, meta, recipients,
)
// The channel was deleted after the lookup above.
if db.IsForeignKeyConstraintError(fanErr) {
return "", noSuchChannel
return 0, "", noSuchChannel
}
if fanErr != nil {
return "", fanErr
return 0, "", fanErr
}
return uuid, nil
return dbID, uuid, nil
}
// SendDirectMessage validates the target and sends a
// 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.
// direct message, returning the message UUID and any away
// message set on the target.
func (s *Service) SendDirectMessage(
ctx context.Context,
sessionID, skipClientID int64,
sessionID int64,
nick, command, target string,
body, meta json.RawMessage,
) (*DirectMsgResult, error) {
@@ -495,7 +506,7 @@ func (s *Service) SendDirectMessage(
_, uuid, fanErr := s.FanOut(
ctx, command, nick, target, targetSID, 0,
nil, body, meta, recipients, skipClientID,
nil, body, meta, recipients,
)
// The target's session was deleted after the lookup above.
if db.IsForeignKeyConstraintError(fanErr) {
@@ -511,12 +522,11 @@ func (s *Service) SendDirectMessage(
// JoinChannel creates or joins a channel, making the
// first joiner the operator. Fans out the JOIN to all
// channel members, skipping client skipClientID as FanOut
// does. A channel name that validChannelRe does not match
// gets ERR_NOSUCHCHANNEL.
// channel members. A channel name that validChannelRe
// does not match gets ERR_NOSUCHCHANNEL.
func (s *Service) JoinChannel(
ctx context.Context,
sessionID, skipClientID int64,
sessionID int64,
nick, channel, suppliedKey string,
) (*JoinResult, error) {
if !validChannelRe.MatchString(channel) {
@@ -539,7 +549,7 @@ func (s *Service) JoinChannel(
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, irc.CmdJoin, nick, channel, 0, chID,
nil, body, nil, memberIDs, skipClientID,
nil, body, nil, memberIDs,
)
return &JoinResult{
@@ -548,12 +558,12 @@ func (s *Service) JoinChannel(
}, nil
}
// PartChannel validates membership, broadcasts PART to all
// channel members, skipping client skipClientID as FanOut
// does, removes the user, and cleans up empty channels.
// PartChannel validates membership, broadcasts PART to
// remaining members, removes the user, and cleans up empty
// channels.
func (s *Service) PartChannel(
ctx context.Context,
sessionID, skipClientID int64,
sessionID int64,
nick, channel, reason string,
) error {
chID, err := s.db.GetChannelByName(ctx, channel)
@@ -577,11 +587,12 @@ 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, memberIDs, skipClientID,
nil, body, nil, recipients,
)
s.db.PartChannelAndDeleteIfEmpty(ctx, chID, sessionID) //nolint:errcheck,gosec
@@ -590,11 +601,10 @@ func (s *Service) PartChannel(
}
// SetTopic validates membership and topic-lock, sets the
// topic, and broadcasts the change to all channel members,
// skipping client skipClientID as FanOut does.
// topic, and broadcasts the change.
func (s *Service) SetTopic(
ctx context.Context,
sessionID, skipClientID int64,
sessionID int64,
nick, channel, topic string,
) error {
chID, err := s.db.GetChannelByName(ctx, channel)
@@ -643,19 +653,18 @@ func (s *Service) SetTopic(
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, irc.CmdTopic, nick, channel, 0, chID,
nil, body, nil, memberIDs, skipClientID,
nil, body, nil, memberIDs,
)
return nil
}
// KickUser validates operator status and target
// membership, broadcasts the KICK to all channel members,
// skipping client skipClientID as FanOut does, removes the
// target, and cleans up empty channels.
// membership, broadcasts the KICK, removes the target,
// and cleans up empty channels.
func (s *Service) KickUser(
ctx context.Context,
sessionID, skipClientID int64,
sessionID int64,
nick, channel, targetNick, reason string,
) error {
chID, err := s.db.GetChannelByName(ctx, channel)
@@ -708,7 +717,7 @@ func (s *Service) KickUser(
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, irc.CmdKick, nick, channel, 0, chID,
params, body, nil, memberIDs, skipClientID,
params, body, nil, memberIDs,
)
s.db.PartChannelAndDeleteIfEmpty(ctx, chID, targetSID) //nolint:errcheck,gosec
@@ -789,11 +798,10 @@ func (s *Service) InviteUser(
}
// ChangeNick changes a user's nickname and broadcasts the
// change to the user's own session and all users sharing
// channels, skipping client skipClientID as FanOut does.
// change to all users sharing channels.
func (s *Service) ChangeNick(
ctx context.Context,
sessionID, skipClientID int64,
sessionID int64,
oldNick, newNick string,
) error {
err := s.db.ChangeNick(ctx, sessionID, newNick)
@@ -814,9 +822,7 @@ func (s *Service) ChangeNick(
}
}
s.broadcastNickChange(
ctx, sessionID, skipClientID, oldNick, newNick,
)
s.broadcastNickChange(ctx, sessionID, oldNick, newNick)
return nil
}
@@ -1036,10 +1042,9 @@ func (s *Service) SetChannelFlag(
}
// BroadcastMode fans out a MODE change to all channel
// members, skipping client skipClientID as FanOut does.
// members.
func (s *Service) BroadcastMode(
ctx context.Context,
skipClientID int64,
nick, channel string,
chID int64,
modeText string,
@@ -1049,7 +1054,7 @@ func (s *Service) BroadcastMode(
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
ctx, irc.CmdMode, nick, channel, 0, chID,
nil, body, nil, memberIDs, skipClientID,
nil, body, nil, memberIDs,
)
}
@@ -1301,12 +1306,11 @@ func (s *Service) checkChannelHashcash(
return nil
}
// broadcastNickChange notifies the user's own session,
// except client skipClientID, and channel peers of a nick
// broadcastNickChange notifies channel peers of a nick
// change.
func (s *Service) broadcastNickChange(
ctx context.Context,
sessionID, skipClientID int64,
sessionID int64,
oldNick, newNick string,
) {
channels, err := s.db.GetSessionChannels(
@@ -1328,7 +1332,7 @@ func (s *Service) broadcastNickChange(
}
// Notify the user themselves (for multi-client sync).
_ = s.db.EnqueueToSession(ctx, sessionID, dbID, skipClientID)
_ = s.db.EnqueueToSession(ctx, sessionID, dbID)
s.broker.Notify(sessionID)
notified[sessionID] = true
@@ -1347,7 +1351,7 @@ func (s *Service) broadcastNickChange(
notified[mid] = true
_ = s.db.EnqueueToSession(ctx, mid, dbID, 0)
_ = s.db.EnqueueToSession(ctx, mid, dbID)
s.broker.Notify(mid)
}
}
+66 -56
View File
@@ -6,6 +6,7 @@ package service_test
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
@@ -22,7 +23,6 @@ import (
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/dbtest"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/hashcash"
"sneak.berlin/go/neoirc/internal/logger"
@@ -46,7 +46,13 @@ type testEnv struct {
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
dbURL := dbtest.NewURL(t)
// A random name, so that no other test can share it, not
// even one that has finished but still holds its database
// open.
dbURL := fmt.Sprintf(
"file:%s?mode=memory&cache=shared",
rand.Text(),
)
var (
database *db.Database
@@ -162,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}, 0,
[]int64{sid1, sid2},
)
if err != nil {
t.Fatalf("FanOut: %v", err)
@@ -184,7 +190,7 @@ func TestJoinChannel(t *testing.T) {
sid := createSession(ctx, t, env.db, "alice")
result, err := env.svc.JoinChannel(
ctx, sid, 0, "alice", "#general", "",
ctx, sid, "alice", "#general", "",
)
if err != nil {
t.Fatalf("JoinChannel: %v", err)
@@ -202,7 +208,7 @@ func TestJoinChannel(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob")
result2, err := env.svc.JoinChannel(
ctx, sid2, 0, "bob", "#general", "",
ctx, sid2, "bob", "#general", "",
)
if err != nil {
t.Fatalf("JoinChannel bob: %v", err)
@@ -224,14 +230,14 @@ func TestPartChannel(t *testing.T) {
sid := createSession(ctx, t, env.db, "alice")
_, err := env.svc.JoinChannel(
ctx, sid, 0, "alice", "#general", "",
ctx, sid, "alice", "#general", "",
)
if err != nil {
t.Fatalf("JoinChannel: %v", err)
}
err = env.svc.PartChannel(
ctx, sid, 0, "alice", "#general", "bye",
ctx, sid, "alice", "#general", "bye",
)
if err != nil {
t.Fatalf("PartChannel: %v", err)
@@ -239,7 +245,7 @@ func TestPartChannel(t *testing.T) {
// Parting a non-existent channel returns error.
err = env.svc.PartChannel(
ctx, sid, 0, "alice", "#nonexistent", "",
ctx, sid, "alice", "#nonexistent", "",
)
if err == nil {
t.Error("expected error for non-existent channel")
@@ -303,7 +309,7 @@ func joinConcurrently(
joins[i] = func() error {
result, err := env.svc.JoinChannel(
ctx, sessionID, 0, nick, channel, "",
ctx, sessionID, nick, channel, "",
)
results[i] = result
@@ -329,7 +335,7 @@ func TestConcurrentJoinsRespectUserLimit(t *testing.T) {
opSID := createSession(ctx, t, env.db, opNick)
created, err := env.svc.JoinChannel(
ctx, opSID, 0, opNick, channel, "",
ctx, opSID, opNick, channel, "",
)
if err != nil {
t.Fatalf("join %s: %v", opNick, err)
@@ -442,7 +448,7 @@ func TestJoinConcurrentWithLastPart(t *testing.T) {
joinerSID := createSession(ctx, t, env.db, joinerNick)
_, err := env.svc.JoinChannel(
ctx, leaverSID, 0, leaverNick, channel, "",
ctx, leaverSID, leaverNick, channel, "",
)
if err != nil {
t.Fatalf("join %s: %v", leaverNick, err)
@@ -451,12 +457,12 @@ func TestJoinConcurrentWithLastPart(t *testing.T) {
errs := runConcurrently(
func() error {
return env.svc.PartChannel(
ctx, leaverSID, 0, leaverNick, channel, "",
ctx, leaverSID, leaverNick, channel, "",
)
},
func() error {
_, joinErr := env.svc.JoinChannel(
ctx, joinerSID, 0, joinerNick, channel, "",
ctx, joinerSID, joinerNick, channel, "",
)
return joinErr
@@ -507,7 +513,7 @@ func TestJoinConcurrentWithLastQuit(t *testing.T) {
joinerSID := createSession(ctx, t, env.db, joinerNick)
created, err := env.svc.JoinChannel(
ctx, leaverSID, 0, leaverNick, channel, "",
ctx, leaverSID, leaverNick, channel, "",
)
if err != nil {
t.Fatalf("join %s: %v", leaverNick, err)
@@ -539,7 +545,7 @@ func TestJoinConcurrentWithLastQuit(t *testing.T) {
)
joined, joinErr = env.svc.JoinChannel(
ctx, joinerSID, 0, joinerNick, channel, "",
ctx, joinerSID, joinerNick, channel, "",
)
if !errors.As(joinErr, &ircErr) ||
ircErr.Code != irc.ErrBadChannelKey {
@@ -586,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, 0, nick, channel, "")
_, err := env.svc.JoinChannel(ctx, quitterSID, nick, channel, "")
if err != nil {
t.Fatalf("join %s: %v", channel, err)
}
@@ -596,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, 0, memberNick, crowd, "")
_, err := env.svc.JoinChannel(ctx, memberSID, memberNick, crowd, "")
if err != nil {
t.Fatalf("join %s: %v", memberNick, err)
}
@@ -611,7 +617,7 @@ func TestQuitQueuedAheadOfNewNickHoldersJoin(t *testing.T) {
t.Fatalf("create session %s: %v", watcherNick, err)
}
_, err = env.svc.JoinChannel(ctx, watcherSID, 0, watcherNick, shared, "")
_, err = env.svc.JoinChannel(ctx, watcherSID, watcherNick, shared, "")
if err != nil {
t.Fatalf("join %s: %v", watcherNick, err)
}
@@ -672,7 +678,7 @@ func takeNickAndJoin(
return err
}
_, err = env.svc.JoinChannel(ctx, sessionID, 0, nick, channel, "")
_, err = env.svc.JoinChannel(ctx, sessionID, nick, channel, "")
return err
}
@@ -704,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, 0, "op", channel, "")
created, err := env.svc.JoinChannel(ctx, opSID, "op", channel, "")
if err != nil {
t.Fatalf("join op: %v", err)
}
@@ -719,17 +725,17 @@ func TestJoinUsesUpInvite(t *testing.T) {
t.Fatalf("invite guest: %v", err)
}
_, err = env.svc.JoinChannel(ctx, guestSID, 0, "guest", channel, "")
_, err = env.svc.JoinChannel(ctx, guestSID, "guest", channel, "")
if err != nil {
t.Fatalf("invited join: %v", err)
}
err = env.svc.PartChannel(ctx, guestSID, 0, "guest", channel, "")
err = env.svc.PartChannel(ctx, guestSID, "guest", channel, "")
if err != nil {
t.Fatalf("part guest: %v", err)
}
_, err = env.svc.JoinChannel(ctx, guestSID, 0, "guest", channel, "")
_, err = env.svc.JoinChannel(ctx, guestSID, "guest", channel, "")
var ircErr *service.IRCError
if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrInviteOnlyChan {
@@ -748,7 +754,7 @@ func TestJoinRefusedByHostmaskBan(t *testing.T) {
opSID := createSession(ctx, t, env.db, "op")
created, err := env.svc.JoinChannel(ctx, opSID, 0, "op", channel, "")
created, err := env.svc.JoinChannel(ctx, opSID, "op", channel, "")
if err != nil {
t.Fatalf("join op: %v", err)
}
@@ -767,7 +773,7 @@ func TestJoinRefusedByHostmaskBan(t *testing.T) {
t.Fatalf("create session guest: %v", err)
}
_, err = env.svc.JoinChannel(ctx, guestSID, 0, "guest", channel, "")
_, err = env.svc.JoinChannel(ctx, guestSID, "guest", channel, "")
var ircErr *service.IRCError
if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrBannedFromChan {
@@ -787,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, 0, "alice", channel, "")
_, err := env.svc.JoinChannel(ctx, aliceSID, "alice", channel, "")
if err != nil {
t.Fatalf("join alice: %v", err)
}
_, err = env.svc.JoinChannel(ctx, bobSID, 0, "bob", channel, "")
_, err = env.svc.JoinChannel(ctx, bobSID, "bob", channel, "")
if err != nil {
t.Fatalf("join bob: %v", err)
}
err = env.svc.PartChannel(ctx, aliceSID, 0, "alice", channel, "")
err = env.svc.PartChannel(ctx, aliceSID, "alice", channel, "")
if err != nil {
t.Fatalf("part alice: %v", err)
}
@@ -812,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, 0, "bob", channel, "")
err = env.svc.PartChannel(ctx, bobSID, "bob", channel, "")
if err != nil {
t.Fatalf("part bob: %v", err)
}
@@ -833,12 +839,12 @@ func TestKickOfLastMemberDeletesChannel(t *testing.T) {
sid := createSession(ctx, t, env.db, "alice")
_, err := env.svc.JoinChannel(ctx, sid, 0, "alice", channel, "")
_, err := env.svc.JoinChannel(ctx, sid, "alice", channel, "")
if err != nil {
t.Fatalf("join alice: %v", err)
}
err = env.svc.KickUser(ctx, sid, 0, "alice", channel, "alice", "")
err = env.svc.KickUser(ctx, sid, "alice", channel, "alice", "")
if err != nil {
t.Fatalf("kick alice: %v", err)
}
@@ -857,14 +863,14 @@ func TestSendChannelMessage(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob")
_, err := env.svc.JoinChannel(
ctx, sid1, 0, "alice", "#chat", "",
ctx, sid1, "alice", "#chat", "",
)
if err != nil {
t.Fatalf("join alice: %v", err)
}
_, err = env.svc.JoinChannel(
ctx, sid2, 0, "bob", "#chat", "",
ctx, sid2, "bob", "#chat", "",
)
if err != nil {
t.Fatalf("join bob: %v", err)
@@ -872,14 +878,18 @@ func TestSendChannelMessage(t *testing.T) {
body, _ := json.Marshal([]string{"hello world"}) //nolint:errchkjson
uuid, err := env.svc.SendChannelMessage(
ctx, sid1, 0, "alice",
dbID, uuid, err := env.svc.SendChannelMessage(
ctx, sid1, "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")
}
@@ -887,8 +897,8 @@ func TestSendChannelMessage(t *testing.T) {
// Non-member cannot send.
sid3 := createSession(ctx, t, env.db, "charlie")
_, err = env.svc.SendChannelMessage(
ctx, sid3, 0, "charlie",
_, _, err = env.svc.SendChannelMessage(
ctx, sid3, "charlie",
irc.CmdPrivmsg, "#chat", body, nil,
)
if err == nil {
@@ -904,14 +914,14 @@ func TestBroadcastQuit(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob")
_, err := env.svc.JoinChannel(
ctx, sid1, 0, "alice", "#room", "",
ctx, sid1, "alice", "#room", "",
)
if err != nil {
t.Fatalf("join alice: %v", err)
}
_, err = env.svc.JoinChannel(
ctx, sid2, 0, "bob", "#room", "",
ctx, sid2, "bob", "#room", "",
)
if err != nil {
t.Fatalf("join bob: %v", err)
@@ -953,7 +963,7 @@ func TestSendDirectMessageTargetQuits(t *testing.T) {
body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson
_, err = env.svc.SendDirectMessage(
ctx, sid, 0, "alice", irc.CmdPrivmsg, "bob", body, nil,
ctx, sid, "alice", irc.CmdPrivmsg, "bob", body, nil,
)
var ircErr *service.IRCError
@@ -973,7 +983,7 @@ func TestSendChannelMessageChannelDeleted(t *testing.T) {
sid := createSession(ctx, t, env.db, "alice")
_, err := env.svc.JoinChannel(ctx, sid, 0, "alice", "#gone", "")
_, err := env.svc.JoinChannel(ctx, sid, "alice", "#gone", "")
if err != nil {
t.Fatalf("join alice: %v", err)
}
@@ -990,8 +1000,8 @@ func TestSendChannelMessageChannelDeleted(t *testing.T) {
body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson
_, err = env.svc.SendChannelMessage(
ctx, sid, 0, "alice", irc.CmdPrivmsg, "#gone", body, nil,
_, _, err = env.svc.SendChannelMessage(
ctx, sid, "alice", irc.CmdPrivmsg, "#gone", body, nil,
)
var ircErr *service.IRCError
@@ -1009,14 +1019,14 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
sid2 := createSession(ctx, t, env.db, "bob")
result, err := env.svc.JoinChannel(
ctx, sid1, 0, "alice", "#modchat", "",
ctx, sid1, "alice", "#modchat", "",
)
if err != nil {
t.Fatalf("join alice: %v", err)
}
_, err = env.svc.JoinChannel(
ctx, sid2, 0, "bob", "#modchat", "",
ctx, sid2, "bob", "#modchat", "",
)
if err != nil {
t.Fatalf("join bob: %v", err)
@@ -1029,8 +1039,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, 0, "bob",
_, _, err = env.svc.SendChannelMessage(
ctx, sid2, "bob",
irc.CmdPrivmsg, "#modchat", body, nil,
)
if err == nil {
@@ -1038,8 +1048,8 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
}
// Alice (operator) should succeed.
_, err = env.svc.SendChannelMessage(
ctx, sid1, 0, "alice",
_, _, err = env.svc.SendChannelMessage(
ctx, sid1, "alice",
irc.CmdPrivmsg, "#modchat", body, nil,
)
if err != nil {
@@ -1064,7 +1074,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, 0, "bob", channel, "")
return env.svc.PartChannel(ctx, bobSID, "bob", channel, "")
}},
{"banned", func(ctx context.Context, env *testEnv, chID, _ int64) error {
return env.db.AddChannelBan(ctx, chID, "bob!*@*", "alice")
@@ -1084,12 +1094,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, 0, "alice", channel, "")
joined, err := env.svc.JoinChannel(ctx, aliceSID, "alice", channel, "")
if err != nil {
t.Fatalf("join alice: %v", err)
}
_, err = env.svc.JoinChannel(ctx, bobSID, 0, "bob", channel, "")
_, err = env.svc.JoinChannel(ctx, bobSID, "bob", channel, "")
if err != nil {
t.Fatalf("join bob: %v", err)
}
@@ -1109,8 +1119,8 @@ func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) {
)
meta, _ := json.Marshal(map[string]string{"hashcash": stamp})
_, err = env.svc.SendChannelMessage(
ctx, bobSID, 0, "bob", irc.CmdPrivmsg, channel, body, meta,
_, _, err = env.svc.SendChannelMessage(
ctx, bobSID, "bob", irc.CmdPrivmsg, channel, body, meta,
)
var ircErr *service.IRCError
@@ -1119,8 +1129,8 @@ func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) {
t.Fatalf("bob: want ERR_CANNOTSENDTOCHAN, got %v", err)
}
_, err = env.svc.SendChannelMessage(
ctx, aliceSID, 0, "alice", irc.CmdPrivmsg, channel, body, meta,
_, _, err = env.svc.SendChannelMessage(
ctx, aliceSID, "alice", irc.CmdPrivmsg, channel, body, meta,
)
if err != nil {
t.Fatalf("alice with the same stamp: %v", err)
+57 -129
View File
@@ -7,11 +7,6 @@ const RECONNECT_DELAY = 3000;
const MEMBER_REFRESH_INTERVAL = 10000;
const ACTION_PREFIX = "\x01ACTION ";
const ACTION_SUFFIX = "\x01";
// SERVER_TAB is the Server tab's name, under which its messages and
// unread count are stored. No nick or channel name can contain *, so a
// user with the nick Server gets a direct-message tab of its own. The
// tab bar labels this tab Server.
const SERVER_TAB = "*server*";
// Hashcash proof-of-work helpers using Web Crypto API.
@@ -275,9 +270,9 @@ function UserList({ members, onNickClick }) {
function App() {
const [loggedIn, setLoggedIn] = useState(false);
const [nick, setNick] = useState("");
const [tabs, setTabs] = useState([{ type: "server", name: SERVER_TAB }]);
const [tabs, setTabs] = useState([{ type: "server", name: "Server" }]);
const [activeTab, setActiveTab] = useState(0);
const [messages, setMessages] = useState({ [SERVER_TAB]: [] });
const [messages, setMessages] = useState({ Server: [] });
const [members, setMembers] = useState({});
const [topics, setTopics] = useState({});
const [unread, setUnread] = useState({});
@@ -360,26 +355,6 @@ function App() {
.catch(() => {});
}, []);
// removeChannelTab closes a channel's tab. The tab the user was
// reading stays active, found by its name; when it was the closed tab,
// the Server tab shows.
const removeChannelTab = useCallback((name) => {
setTabs((prev) => {
const remaining = prev.filter(
(t) => !(t.type === "channel" && t.name === name),
);
// Preact runs this update at once, on the newest active tab.
setActiveTab((active) => {
const reading = prev[active]?.name;
const idx = remaining.findIndex((t) => t.name === reading);
return idx >= 0 ? idx : 0;
});
return remaining;
});
}, []);
const processMessage = useCallback(
(msg) => {
const body = Array.isArray(msg.body) ? msg.body.join("\n") : "";
@@ -449,13 +424,6 @@ function App() {
break;
}
case "PART": {
// The user's own PART, sent from this client or another,
// closes the tab and adds no message or unread count.
if (msg.from === nickRef.current) {
removeChannelTab(msg.to);
break;
}
const reason = body ? " (" + body + ")" : "";
const text = `${msg.from} has parted ${msg.to}${reason}`;
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
@@ -463,29 +431,6 @@ function App() {
break;
}
case "KICK": {
// The kicked nick is in params; the body is the reason.
const kicked = Array.isArray(msg.params) ? msg.params[0] : "";
const reason = body ? " (" + body + ")" : "";
const text = `${kicked} was kicked from ${msg.to} by ${msg.from}${reason}`;
if (msg.to) addMessage(msg.to, { ...base, text, system: true });
if (kicked === nickRef.current) {
// The channel's tab closes, so the Server tab gets the line
// too. It needs an id of its own: addMessage drops a message
// whose id it has already seen.
addMessage(SERVER_TAB, {
...base,
id: msg.id + "-server",
text,
system: true,
});
removeChannelTab(msg.to);
} else if (msg.to && msg.to.startsWith("#")) {
refreshMembers(msg.to);
}
break;
}
case "QUIT": {
const reason = body ? " (" + body + ")" : "";
const text = `${msg.from} has quit${reason}`;
@@ -573,7 +518,7 @@ function App() {
const chName = msg.params[0];
const count = msg.params[1];
const chTopic = body || "";
addMessage(SERVER_TAB, {
addMessage("Server", {
...base,
text: `${chName} (${count} users): ${chTopic.trim()}`,
system: true,
@@ -583,7 +528,7 @@ function App() {
break;
}
case 323:
addMessage(SERVER_TAB, {
addMessage("Server", {
...base,
text: body || "End of channel list",
system: true,
@@ -596,7 +541,7 @@ function App() {
const whoCh = msg.params[0];
const whoNick = msg.params[4];
const whoFlags = msg.params.length > 5 ? msg.params[5] : "";
addMessage(SERVER_TAB, {
addMessage("Server", {
...base,
text: `${whoCh} ${whoNick} ${whoFlags}`,
system: true,
@@ -606,7 +551,7 @@ function App() {
break;
}
case 315:
addMessage(SERVER_TAB, {
addMessage("Server", {
...base,
text: body || "End of /WHO list",
system: true,
@@ -617,7 +562,7 @@ function App() {
// RPL_WHOISUSER — params nick, user, host, *; body realname.
if (Array.isArray(msg.params) && msg.params.length >= 3) {
const [wiNick, wiUser, wiHost] = msg.params;
addMessage(SERVER_TAB, {
addMessage("Server", {
...base,
text: `${wiNick} is ${wiUser}@${wiHost} (${body})`,
system: true,
@@ -631,7 +576,7 @@ function App() {
if (Array.isArray(msg.params) && msg.params.length >= 2) {
const wiNick = msg.params[0];
const wiServer = msg.params[1];
addMessage(SERVER_TAB, {
addMessage("Server", {
...base,
text: `${wiNick} on ${wiServer}`,
system: true,
@@ -643,7 +588,7 @@ function App() {
case 313: {
// RPL_WHOISOPERATOR — nick; body "is an IRC operator".
if (Array.isArray(msg.params) && msg.params.length >= 1) {
addMessage(SERVER_TAB, {
addMessage("Server", {
...base,
text: `${msg.params[0]} ${body}`,
system: true,
@@ -657,7 +602,7 @@ function App() {
if (Array.isArray(msg.params) && msg.params.length >= 3) {
const [wiNick, idle, signon] = msg.params;
const signedOn = new Date(Number(signon) * 1000).toLocaleString();
addMessage(SERVER_TAB, {
addMessage("Server", {
...base,
text: `${wiNick} has been idle ${idle}s, signed on ${signedOn}`,
system: true,
@@ -670,7 +615,7 @@ function App() {
// RPL_WHOISCHANNELS — nick, channels.
if (Array.isArray(msg.params) && msg.params.length >= 1) {
const wiNick = msg.params[0];
addMessage(SERVER_TAB, {
addMessage("Server", {
...base,
text: `${wiNick} is on: ${body}`,
system: true,
@@ -680,7 +625,7 @@ function App() {
break;
}
case 318:
addMessage(SERVER_TAB, {
addMessage("Server", {
...base,
text: body || "End of /WHOIS list",
system: true,
@@ -690,7 +635,7 @@ function App() {
case 324: {
// RPL_CHANNELMODEIS — channel, mode string.
if (Array.isArray(msg.params) && msg.params.length >= 2) {
addMessage(SERVER_TAB, {
addMessage("Server", {
...base,
text: `${msg.params[0]} modes: ${msg.params[1]}`,
system: true,
@@ -702,16 +647,16 @@ function App() {
case 375:
case 372:
case 376:
addMessage(SERVER_TAB, { ...base, text: body, system: true });
addMessage("Server", { ...base, text: body, system: true });
break;
default:
if (body) {
addMessage(SERVER_TAB, { ...base, text: body, system: true });
addMessage("Server", { ...base, text: body, system: true });
}
}
},
[addMessage, refreshMembers, removeChannelTab],
[addMessage, refreshMembers],
);
// Long-poll loop.
@@ -833,7 +778,7 @@ function App() {
);
setNick(userNick);
setLoggedIn(true);
addSystemMessage(SERVER_TAB, `Connected as ${userNick}`);
addSystemMessage("Server", `Connected as ${userNick}`);
if (isResumed) {
// Request MOTD on resumed sessions (new sessions
@@ -857,11 +802,10 @@ function App() {
// Fresh session — join any previously saved channels.
for (const ch of saved) {
try {
const res = await api("/messages", {
await api("/messages", {
method: "POST",
body: JSON.stringify({ command: "JOIN", to: ch }),
});
if (res?.status !== "joined") continue;
setTabs((prev) => {
if (prev.find((t) => t.type === "channel" && t.name === ch))
return prev;
@@ -881,30 +825,18 @@ function App() {
name = name.trim();
if (!name.startsWith("#")) name = "#" + name;
try {
const res = await api("/messages", {
await api("/messages", {
method: "POST",
body: JSON.stringify({ command: "JOIN", to: name }),
});
// A refused join opens no tab. The error numeric that the
// server sends to the message queue tells the user why.
if (res?.status !== "joined") return;
// Show the channel's tab. Preact runs this update at once on the
// newest tab list, so the tab is found there by name: the user's
// own JOIN event may already have added it, and other tabs may
// have closed.
setTabs((prev) => {
const idx = prev.findIndex(
(t) => t.type === "channel" && t.name === name,
);
if (idx >= 0) {
setActiveTab(idx);
if (prev.find((t) => t.type === "channel" && t.name === name))
return prev;
}
setActiveTab(prev.length);
return [...prev, { type: "channel", name }];
});
const newIdx = tabsRef.current.length;
setActiveTab(newIdx);
try {
const hist = await api(
`/history?target=${encodeURIComponent(name)}&limit=50`,
@@ -917,7 +849,7 @@ function App() {
}
} catch (err) {
addSystemMessage(
SERVER_TAB,
"Server",
`Failed to join ${name}: ${err.data?.error || "error"}`,
);
}
@@ -933,7 +865,10 @@ function App() {
} catch (e) {
// Ignore.
}
removeChannelTab(name);
setTabs((prev) =>
prev.filter((t) => !(t.type === "channel" && t.name === name)),
);
setActiveTab(0);
};
const closeTab = (idx) => {
@@ -948,23 +883,16 @@ function App() {
const openDM = (targetNick) => {
if (targetNick === nickRef.current) return;
// Show the direct-message tab. Preact runs this update at once on
// the newest tab list, so the tab is found there by name: the
// server's copy of the user's message may already have added it,
// and other tabs may have opened or closed.
setTabs((prev) => {
const idx = prev.findIndex(
(t) => t.type === "dm" && t.name === targetNick,
);
if (idx >= 0) {
setActiveTab(idx);
if (prev.find((t) => t.type === "dm" && t.name === targetNick))
return prev;
}
setActiveTab(prev.length);
return [...prev, { type: "dm", name: targetNick }];
});
const idx = tabs.findIndex(
(t) => t.type === "dm" && t.name === targetNick,
);
setActiveTab(idx >= 0 ? idx : tabs.length);
};
const handleCommand = async (text) => {
@@ -975,7 +903,7 @@ function App() {
switch (cmd) {
case "/join": {
if (parts[1]) joinChannel(parts[1]);
else addSystemMessage(SERVER_TAB, "Usage: /join #channel");
else addSystemMessage("Server", "Usage: /join #channel");
break;
}
@@ -984,7 +912,7 @@ function App() {
const reason = parts.slice(1).join(" ") || undefined;
partChannel(tab.name, reason);
} else {
addSystemMessage(SERVER_TAB, "You are not in a channel");
addSystemMessage("Server", "You are not in a channel");
}
break;
@@ -1005,19 +933,19 @@ function App() {
openDM(target);
} catch (err) {
addSystemMessage(
SERVER_TAB,
"Server",
`Message failed: ${err.data?.error || "error"}`,
);
}
} else {
addSystemMessage(SERVER_TAB, "Usage: /msg <nick> <message>");
addSystemMessage("Server", "Usage: /msg <nick> <message>");
}
break;
}
case "/me": {
if (tab.type === "server") {
addSystemMessage(SERVER_TAB, "Cannot use /me in server window");
addSystemMessage("Server", "Cannot use /me in server window");
break;
}
@@ -1039,7 +967,7 @@ function App() {
);
}
} else {
addSystemMessage(SERVER_TAB, "Usage: /me <action>");
addSystemMessage("Server", "Usage: /me <action>");
}
break;
@@ -1053,19 +981,19 @@ function App() {
});
} catch (err) {
addSystemMessage(
SERVER_TAB,
"Server",
`Nick change failed: ${err.data?.error || "error"}`,
);
}
} else {
addSystemMessage(SERVER_TAB, "Usage: /nick <newnick>");
addSystemMessage("Server", "Usage: /nick <newnick>");
}
break;
}
case "/topic": {
if (tab.type !== "channel") {
addSystemMessage(SERVER_TAB, "You are not in a channel");
addSystemMessage("Server", "You are not in a channel");
break;
}
@@ -1082,13 +1010,13 @@ function App() {
});
} catch (err) {
addSystemMessage(
SERVER_TAB,
"Server",
`Topic change failed: ${err.data?.error || "error"}`,
);
}
} else {
addSystemMessage(
SERVER_TAB,
"Server",
`Current topic for ${tab.name}: ${topics[tab.name] || "(none)"}`,
);
}
@@ -1097,7 +1025,7 @@ function App() {
}
case "/mode": {
if (tab.type !== "channel") {
addSystemMessage(SERVER_TAB, "You are not in a channel");
addSystemMessage("Server", "You are not in a channel");
break;
}
@@ -1115,7 +1043,7 @@ function App() {
});
} catch (err) {
addSystemMessage(
SERVER_TAB,
"Server",
`MODE failed: ${err.data?.error || "error"}`,
);
}
@@ -1146,7 +1074,7 @@ function App() {
});
} catch (err) {
addSystemMessage(
SERVER_TAB,
"Server",
`Failed to request MOTD: ${err.data?.error || "error"}`,
);
}
@@ -1170,13 +1098,13 @@ function App() {
});
} catch (err) {
addSystemMessage(
SERVER_TAB,
"Server",
`Message failed: ${err.data?.error || "error"}`,
);
}
}
} else {
addSystemMessage(SERVER_TAB, "Usage: /query <nick> [message]");
addSystemMessage("Server", "Usage: /query <nick> [message]");
}
break;
@@ -1189,7 +1117,7 @@ function App() {
});
} catch (err) {
addSystemMessage(
SERVER_TAB,
"Server",
`Failed to list channels: ${err.data?.error || "error"}`,
);
}
@@ -1206,12 +1134,12 @@ function App() {
});
} catch (err) {
addSystemMessage(
SERVER_TAB,
"Server",
`WHO failed: ${err.data?.error || "error"}`,
);
}
} else {
addSystemMessage(SERVER_TAB, "Usage: /who #channel");
addSystemMessage("Server", "Usage: /who #channel");
}
break;
@@ -1225,12 +1153,12 @@ function App() {
});
} catch (err) {
addSystemMessage(
SERVER_TAB,
"Server",
`WHOIS failed: ${err.data?.error || "error"}`,
);
}
} else {
addSystemMessage(SERVER_TAB, "Usage: /whois <nick>");
addSystemMessage("Server", "Usage: /whois <nick>");
}
break;
@@ -1261,13 +1189,13 @@ function App() {
" /help — Show this help",
];
for (const line of helpLines) {
addSystemMessage(SERVER_TAB, line);
addSystemMessage("Server", line);
}
break;
}
default:
addSystemMessage(SERVER_TAB, `Unknown command: ${cmd}`);
addSystemMessage("Server", `Unknown command: ${cmd}`);
}
};
@@ -1296,7 +1224,7 @@ function App() {
if (tab.type === "server") {
addSystemMessage(
SERVER_TAB,
"Server",
"Cannot send messages to the server window. Use /join #channel first.",
);
@@ -1367,7 +1295,7 @@ function App() {
key={tab.name}
>
<span class="tab-label">
{tab.type === "server" ? "Server" : tab.name}
{tab.type === "dm" ? tab.name : tab.name}
</span>
{unread[tab.name] > 0 && i !== activeTab && (
<span class="unread-count">({unread[tab.name]})</span>