Author SHA1 Message Date
sneak 83a3245d5d Make the schema files match the server (closes #218)
check / check (push) Waiting to run
Each file in schema/ now describes what the HTTP API on next sends and
accepts. A numeric reply has its name in command and its number in code.
body is always an array of strings. MODE, KICK and JOIN carry their
values in body, and TOPIC needs a non-empty body. PING takes no token,
and its PONG is the HTTP response. A channel name is limited to 63
characters, and the leading # is optional from a client.

PUBKEY.json is deleted, since the server has no such command. The
section of schema/README.md that listed the differences is removed,
and the sentences it made false are corrected.

Model: opus-5-5
2026-11-02 04:47:22 +00:00
clawbot 7968be00e7 Stop the earlier poll loop on a second /connect (closes #228)
check / check (push) Waiting to run
A second /connect started a new poll loop without stopping the first.
The first loop then polled with the new client too, so each message
could be shown twice. It also read stopPoll without a.mu while
cmdConnect wrote it.

cmdConnect now closes the earlier loop's channel in the same locked
step that replaces the client and the cursor, and hands the new
channel to the new loop. The loop checks its channel under a.mu
before each poll and again before it moves the cursor, so a stopped
loop never polls with the new client or moves the new cursor.

Model: opus-5-5
2026-11-02 05:38:07 +01:00
clawbot acb9527944 Make the PASS notice name the HTTP API login (closes #217)
check / check (push) Waiting to run
After PASS on the IRC listener, the confirming NOTICE told the user to
reconnect with PASS and their nick. The IRC listener cannot add a
connection to an existing session, so that step does not work. The
notice now says the password is set and that another client can log in
to the session over the HTTP API with POST /api/v1/login, as the README
describes. The test now checks the whole notice text.

Model: opus-5-5
2026-11-02 05:27:27 +01:00
clawbot e7ae675155 Send peers the IRC listener user's QUIT reason (closes #216)
check / check (push) Waiting to run
handleQuit now records the reason on the connection, and cleanup
passes it to BroadcastQuit, the same service call the HTTP API's
QUIT uses. A QUIT without a reason still gives Client quit. A
connection that ends without a QUIT still gives Connection closed.

The relay test that expected Connection closed for a bare QUIT now
expects Client quit.

Model: opus-5-5
2026-11-02 05:02:33 +01:00
clawbot 6a7989e08d List the missing channel modes in the 004 reply (closes #238)
check / check (push) Waiting to run
The channel-modes parameter of RPL_MYINFO left out modes that each
transport's MODE accepts. The IRC listener now sends Himnostv, adding
H, i, o and v. The HTTP API now sends Hbiklmnostv, adding H, b and v.
Each transport keeps its own list, because they accept different
modes. The tests compare the whole parameter, so a missing or extra
mode fails them. The README and the schema notes show the new HTTP API
list.

Model: opus-5-5
2026-11-02 04:48:18 +01:00
clawbot cc18397ee1 List the user modes in the 004 reply (closes #220)
check / check (push) Waiting to run
Both transports sent an empty string as the user modes in RPL_MYINFO.
On the IRC listener that left an empty parameter in the middle of the
line, so a client that splits on spaces read the channel modes as the
user modes. The reply now lists o and w, the user modes the server
supports. Both transports read them from one constant in
internal/service, next to the code that decides which user modes MODE
accepts. The README and the schema notes now show what the HTTP API
sends.

Model: opus-5-5
2026-11-02 04:17:09 +01:00
clawbot 0662e87f49 Keep the connection state in the status bar on /window (closes #225)
check / check (push) Waiting to run
/window set the status bar to [connected] whatever the connection
state, and Alt and a digit runs /window. Before /connect, both made
the status bar say [connected] while the client had no session.

/window now reads the connection state with the nick and shows
[connected] only while the client is connected, [disconnected]
otherwise. TestWindowConnectionState types /window 0 and presses
Alt and 0, connected and not, and checks the status bar.

Model: opus-5-5
2026-11-02 03:13:09 +01:00
clawbot 6387d68786 Show the connection state in the neoirc-cli status bar (closes #229)
check / check (push) Waiting to run
The status bar had colour tags turned on, so tview read "[connected]"
and "[disconnected]" as tags and dropped them. It dropped all or part
of a nick, a target or a window name holding square brackets the same
way.

The status bar now has colour tags turned off. It wrote none of its
own, so every bracket shows as it is. TestStartUp checks that the
status bar shows [disconnected] at start-up. TestStatusBar checks
[connected], and checks nicks and a /query target that hold brackets.

Model: opus-5-5
2026-11-02 02:53:10 +01:00
clawbot 59f1208d8d Show the neoirc-cli screen without waiting at start-up (closes #226)
check / check (push) Waiting to run
Run added the start-up status bar and welcome lines through
SetStatus and AddStatus before it started the tview event loop. Both
wait for that loop, so neoirc-cli stopped before it showed its
screen. They now run on a goroutine, as typed lines do.

Run now passes a new UI to run, which holds the start-up path and
returns its error. TestStartUp calls run with a simulation screen. It
fails unless, within five seconds, the status window holds two lines:
first the one with "Welcome to neoirc-cli", then the /connect hint.

Model: opus-5-5
2026-11-02 02:05:24 +01:00
clawbot fe734af375 Keep neoirc-cli responding when a key or a typed line updates the screen (closes #223)
check / check (push) Waiting to run
The Enter handler of the input line and the Alt and digit key handler
ran the typed line on the tview event loop. Every UI method that
changes the screen waits for that loop, so the client stopped
responding. Each line now runs on its own goroutine, which first waits
for the line before it, so lines keep the order they were typed in.

/window now runs off the event loop. It reads the buffer name through
the new BufferName, which runs on the loop, and switches only when
that buffer exists.

One new test drives a running UI on a simulation screen with Alt and
1, then a typed line and Enter. Another holds the first line and
checks that the second waits for it.

Model: opus-5-5
2026-11-02 01:08:43 +01:00
clawbot 967930f9bc Make Alt and a digit set the send target as /window does (closes #221)
check / check (push) Canceled after 0s
In neoirc-cli, Alt and a digit switched the window but kept the send
target, so typed text went to the window the user had left. The key
now runs /window with that digit through the input handler.

/window 0 now clears the send target. The status window has no target,
but the command used to keep the previous one.

Model: opus-5-5
2026-10-28 01:30:33 +01:00
clawbot fe23bb5045 Stop startup when a setting does not parse (closes #219)
check / check (push) Canceled after 0s
config.New read the number and true/false settings with viper's
GetInt, GetFloat64 and GetBool. These return zero or false for a value
that does not parse, so a typo silently changed how the server ran.

config.New now parses these settings with strconv and checks the
duration settings with time.ParseDuration. Each number and duration
must also be finite and above zero. PORT and NEOIRC_HASHCASH_BITS may
be zero: port 0 lets the system pick a port, and 0 bits turns proof of
work off. A bad value makes config.New return an error naming the
setting and its value, and startup stops. Unset settings keep their
defaults.

Model: opus-5-5
2026-10-28 00:47:53 +01:00
clawbot 83a1585ddc Rewrite schema/README.md sentence by sentence
check / check (push) Canceled after 0s
Rewrite the description of the JSON Schema files from scratch, without
tables. Describe the fields of a message as the HTTP API sends them:
the reply name in `command` and its number in `code`. Drop the
federation section, which describes nothing the server does. List each
place where a schema file disagrees with the HTTP API, so that a reader
is not misled. The files themselves are unchanged.

Part of #190.

Model: fable-5-1 (writing); opus-5-5 (rework)
2026-10-27 16:09:17 +01:00
clawbot ecf47c37f5 Start test servers on a port the system picks (closes #211)
check / check (push) Canceled after 0s
The internal/server and internal/ircserver tests picked a port by
opening a listener, reading its port and closing it. They then started
a server on that number. Another process could take the port in
between, and a test failed with "address already in use".

The IRC server tests now start the server on 127.0.0.1:0 and read the
port back from its listener. The HTTP server tests start the app with
port 0. The HTTP server now keeps the listener it binds, so the stop
test sends its long poll to the port the app was given.

Model: opus-5-5
2026-10-27 13:34:45 +01:00
clawbot d444831f41 Keep each test database when the pool replaces its connection (closes #209)
check / check (push) Canceled after 0s
SQLite deletes a shared in-memory database when its last connection
closes. A query cancelled while it runs makes the pool close its
connection, so a test could lose all its tables and rows at random.

The new package internal/dbtest has NewURL. It returns the URL of a
new in-memory database with a random name and holds one connection to
it open until the test ends. The db, handlers, ircserver, server and
service tests take their database URL from it. The test-support lint
rule keeps the package out of the server binary.

A new test cancels a query on such a database, checks with a TEMP
table that the connection was replaced, and checks that a row is
still there.

Model: opus-5-5
2026-10-27 12:26:48 +01:00
clawbot 53009cf9a0 Keep foreign keys on for every database connection (closes #207)
check / check (push) Canceled after 0s
SQLite keeps the foreign keys setting per connection, and the server
turned it on once with a PRAGMA. A query cancelled while it runs makes
the pool replace its only connection, and the new one ran without
foreign keys until a restart.

connect now adds `_pragma=foreign_keys(1)` to the database URL. The
driver runs it on every connection it opens. The server refuses to
start when DBURL sets foreign_keys itself, because the driver sorts
the pragmas and that one could win.

One test cancels a query and checks, with a TEMP table, that the
connection was replaced and that foreign keys are on. Another checks
the startup error for several spellings.

Model: opus-5-5
2026-10-27 11:14:01 +01:00
clawbot d8f8748525 Let an IRC relay finish its queue read when its connection ends (closes #205)
check / check (push) Canceled after 0s
When an IRC listener connection ends, serve cancels the context its
relay uses. If the cancel came while the relay was reading the queue,
the SQLite driver interrupted the query and reported the connection
broken, and the pool closed its only connection. In the tests that
deletes the in-memory database, so in TestIntegrationKill alice never
got bob's QUIT. In production the new connection runs without
PRAGMA foreign_keys = ON.

The relay now reads the queue without that cancel. It still stops at
its next check of the context.

Model: opus-5-5
2026-10-27 10:20:09 +01:00
clawbot a7f9bebe6a Run the internal/handlers tests in parallel (closes #194)
check / check (push) Canceled after 0s
The handlers tests ran one at a time because config.New writes the
global viper instance. Each test spends most of its time in SQLite
under the race detector, so run in a row they took longer than the
20-second target.

Every test and subtest now calls t.Parallel(), as the ircserver and
db tests already do. A mutex in newTestServerWith makes tests start
their servers one at a time, since config.New writes viper and the
server reads it as it starts. No test is removed or otherwise
changed.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-27 08:28:28 +01:00
clawbot c61cc847d9 Show the direct-message web tab even when other tabs change first (closes #202)
check / check (push) Canceled after 0s
openDM took the direct-message tab's position from the tab list of the
render its caller came from. /msg calls it only after the PRIVMSG
request returns. By then the server's copy of the message, or a join or
part from another client of the session, may have changed the tabs. The
active tab then became another tab or none, and the next typed line went
to the wrong place or was dropped.

openDM now finds the tab by name inside its setTabs update and makes it
active there, as joinChannel does.

Model: opus-5-5
2026-10-27 07:07:39 +01:00
clawbot d3b169a641 Give the web Server tab a name no nick can take (closes #200)
check / check (push) Canceled after 0s
The web client stored each tab's messages and unread count under the
tab's name, and the Server tab was named Server. A user with the nick
Server got a direct-message tab with the same name, so the two tabs
showed one message list.

The Server tab is now stored under "*server*". No nick or channel name
can contain *, so that user's tab keeps its own messages. The tab bar
still labels the tab Server.

Model: opus-5-5
2026-10-27 06:29:27 +01:00
clawbot 2331c15aad Keep the web tab the user reads, and show a kick in the Server tab (closes #195)
check / check (push) Canceled after 0s
removeChannelTab made the Server tab active whenever it closed a
channel's tab. When a part or kick came from another client, the user
lost the channel they were reading.

It now finds the tab the user was reading by its name among the
remaining tabs and keeps it active. Only when that was the closed tab
does the Server tab show.

When the kicked nick is the user's own, the kick line also goes into
the Server tab, which counts it as unread. That copy carries the
event's id with "-server" added, since addMessage drops an id it has
already seen.

Model: opus-5-5
2026-10-27 05:52:48 +01:00
clawbot 229c89e1f8 Open a channel only when the server accepts the join (closes #197)
check / check (push) Canceled after 0s
When the server refuses a JOIN, POST /api/v1/messages answers 200
with status "error" and sends the error numeric to the queue. Both
clients opened the channel anyway.

JoinChannel in internal/cli/api now returns an error unless the
status is "joined". cmdJoin then keeps its buffer and target and
prints its usual "Join failed" line. The web client opens no tab for
a refused join, whether it came from /join or from the saved
channels a fresh session joins again.

Model: opus-5-5
2026-10-27 05:09:01 +01:00
clawbot fcc94ca700 Show a joined channel's web tab whichever reply comes first (closes #193)
check / check (push) Canceled after 0s
After a join request returned, joinChannel made tab number
tabsRef.current.length active. When the user's own JOIN event had
already added the tab, or the tab was already open, that index was one
past the last tab. The view then fell back to the Server tab and typed
lines were dropped.

joinChannel now finds the channel's tab by name inside its own tab-list
update and makes that tab active there. Preact runs that update at once
on the newest tab list, so the position is right even when the JOIN
event, or the close of another tab, landed first.

Model: opus-5-5
2026-10-27 04:30:41 +01:00
58 changed files with 2657 additions and 586 deletions
+4
View File
@@ -61,6 +61,10 @@ 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:
+3 -2
View File
@@ -1236,7 +1236,7 @@ IRC listener sends the usual 3-digit code. Over the HTTP API, 005 arrives named
| `001` | RPL_WELCOME | After session creation or login, on MOTD | `{"command":"RPL_WELCOME","code":1,"to":"alice","body":["Welcome to the network, alice"]}` |
| `002` | RPL_YOURHOST | After session creation or login, on MOTD | `{"command":"RPL_YOURHOST","code":2,"to":"alice","body":["Your host is neoirc, running version 0.1"]}` |
| `003` | RPL_CREATED | After session creation or login, on MOTD | `{"command":"RPL_CREATED","code":3,"to":"alice","body":["This server was created 2026-02-10"]}` |
| `004` | RPL_MYINFO | After session creation or login, on MOTD | `{"command":"RPL_MYINFO","code":4,"to":"alice","params":["neoirc","0.1","","ikmnostl"]}` |
| `004` | RPL_MYINFO | After session creation or login, on MOTD | `{"command":"RPL_MYINFO","code":4,"to":"alice","params":["neoirc","0.1","ow","Hbiklmnostv"]}` |
| `005` | RPL_ISUPPORT | After session creation or login, on MOTD | `{"command":"RPL_BOUNCE","code":5,"to":"alice","params":["CHANTYPES=#","NICKLEN=32","PREFIX=(ov)@+","CHANMODES=b,k,Hl,imnst","NETWORK=neoirc","CASEMAPPING=ascii"],"body":["are supported by this server"]}` |
| `221` | RPL_UMODEIS | In response to user MODE | `{"command":"RPL_UMODEIS","code":221,"to":"alice","body":["+"]}` |
| `251` | RPL_LUSERCLIENT | On connect, MOTD or LUSERS | `{"command":"RPL_LUSERCLIENT","code":251,"to":"alice","body":["There are 5 users and 0 invisible on 1 servers"]}` |
@@ -2611,7 +2611,7 @@ listener.
| Variable | Type | Default | Description |
| ---------------------- | ------ | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PORT` | int | `8080` | HTTP listen port |
| `DBURL` | string | `file:///var/lib/neoirc/state.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)` | SQLite connection string. For file-based: `file:///path/to/db.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)`. For in-memory (testing): `file::memory:?cache=shared`. See [SQLite Considerations](#sqlite-considerations). |
| `DBURL` | string | `file:///var/lib/neoirc/state.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)` | SQLite connection string. For file-based: `file:///path/to/db.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)`. For in-memory (testing): `file::memory:?cache=shared`. It must not set `foreign_keys`. See [SQLite Considerations](#sqlite-considerations). |
| `DEBUG` | bool | `false` | Switch logging to debug level |
| `MESSAGE_MAX_AGE` | string | `720h` | Maximum age of messages as a Go duration string (e.g. `720h`, `24h`). Messages older than this are pruned. Default is 30 days. |
| `SESSION_IDLE_TIMEOUT` | string | `720h` | Idle timeout as a Go duration string (e.g. `720h`, `24h`). Clients with no authenticated HTTP API request or IRC listener command for this long are removed, and a session left with none is expired and its nick released (see [Data Lifecycle](#data-lifecycle)). Default is 30 days. |
@@ -3342,6 +3342,7 @@ neoirc/
│ │ └── schema/
│ │ ├── 000.sql # schema_migrations bootstrap
│ │ └── 001_initial.sql
│ ├── dbtest/ # Gives each test its own database; only tests import it
│ ├── globals/ # Application-wide metadata
│ │ └── globals.go
│ ├── handlers/ # HTTP request handlers
+28 -3
View File
@@ -25,7 +25,10 @@ const (
httpErrThreshold = 400
)
var errHTTP = errors.New("HTTP error")
var (
errHTTP = errors.New("HTTP error")
errJoinRefused = errors.New("the server refused the join")
)
// Client wraps HTTP calls to the neoirc server API.
type Client struct {
@@ -206,13 +209,35 @@ func (client *Client) PollMessages(
}, nil
}
// JoinChannel joins a channel.
// 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.
func (client *Client) JoinChannel(channel string) error {
return client.SendMessage(
data, err := client.do(
http.MethodPost, "/api/v1/messages",
&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.
+90 -36
View File
@@ -34,30 +34,44 @@ type App struct {
// Run creates and runs the CLI application.
func Run() {
app := &App{ //nolint:exhaustruct
ui: NewUI(),
nick: "guest",
}
app.ui.OnInput(app.handleInput)
app.ui.SetStatus(app.nick, "", "disconnected")
app.ui.AddStatus(
"Welcome to neoirc-cli — an IRC-style client",
)
app.ui.AddStatus(
"Type [yellow]/connect <server-url>" +
"[white] to begin, " +
"or [yellow]/help[white] for commands",
)
err := app.ui.Run()
err := run(NewUI())
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
// run starts the client on ui and returns when ui stops. Tests
// call it with ui on a simulation screen.
func run(ui *UI) error {
app := &App{ //nolint:exhaustruct
ui: ui,
nick: "guest",
}
app.ui.OnInput(app.handleInput)
// SetStatus and AddStatus wait for the event loop, which
// app.ui.Run starts below, so they run on a goroutine. The
// nick is read first, as a typed /nick may change app.nick.
nick := app.nick
go func() {
app.ui.SetStatus(nick, "", "disconnected")
app.ui.AddStatus(
"Welcome to neoirc-cli — an IRC-style client",
)
app.ui.AddStatus(
"Type [yellow]/connect <server-url>" +
"[white] to begin, " +
"or [yellow]/help[white] for commands",
)
}()
return app.ui.Run()
}
func (a *App) handleInput(text string) {
if strings.HasPrefix(text, "/") {
a.handleCommand(text)
@@ -185,11 +199,21 @@ func (a *App) cmdConnect(serverURL string) {
return
}
// The poll loop of an earlier connection is stopped in the same
// step that replaces the client, so it never polls with the new
// one.
a.mu.Lock()
a.client = client
a.nick = resp.Nick
a.connected = true
a.lastQID = 0
if a.stopPoll != nil {
close(a.stopPoll)
}
stop := make(chan struct{})
a.stopPoll = stop
a.mu.Unlock()
a.ui.AddStatus(fmt.Sprintf(
@@ -198,9 +222,7 @@ func (a *App) cmdConnect(serverURL string) {
))
a.ui.SetStatus(resp.Nick, "", "connected")
a.stopPoll = make(chan struct{})
go a.pollLoop()
go a.pollLoop(stop)
}
func (a *App) cmdNick(nick string) {
@@ -610,24 +632,37 @@ func (a *App) cmdWindow(args string) {
_, _ = fmt.Sscanf(args, "%d", &bufIndex)
a.ui.SwitchBuffer(bufIndex)
a.mu.Lock()
nick := a.nick
connected := a.connected
a.mu.Unlock()
if bufIndex >= 0 && bufIndex < a.ui.BufferCount() {
buf := a.ui.buffers[bufIndex]
if buf.Name != "(status)" {
connStatus := "disconnected"
if connected {
connStatus = "connected"
}
// Read the name before switching. A message from the server can
// add a buffer at any time, but only at the end, so bufIndex
// still names this buffer when SwitchBuffer runs.
name, found := a.ui.BufferName(bufIndex)
if found {
a.ui.SwitchBuffer(bufIndex)
if name != "(status)" {
a.mu.Lock()
a.target = buf.Name
a.target = name
a.mu.Unlock()
a.ui.SetStatus(
nick, buf.Name, "connected",
nick, name, connStatus,
)
} else {
a.ui.SetStatus(nick, "", "connected")
a.mu.Lock()
a.target = ""
a.mu.Unlock()
a.ui.SetStatus(nick, "", connStatus)
}
}
}
@@ -675,16 +710,23 @@ func (a *App) cmdHelp() {
}
}
// pollLoop long-polls for messages in the background.
func (a *App) pollLoop() {
// pollLoop long-polls for messages in the background until stop is
// closed. A later /connect closes stop under a.mu as it replaces the
// client and the cursor, so the loop checks stop under a.mu too. A
// stopped loop then neither polls with the new client nor moves the
// new cursor.
func (a *App) pollLoop(stop <-chan struct{}) {
for {
a.mu.Lock()
select {
case <-a.stopPoll:
case <-stop:
a.mu.Unlock()
return
default:
}
a.mu.Lock()
client := a.client
lastQID := a.lastQID
a.mu.Unlock()
@@ -702,12 +744,24 @@ func (a *App) pollLoop() {
continue
}
if result.LastID > 0 {
a.mu.Lock()
a.lastQID = result.LastID
// A poll sent before stop was closed answers for the earlier
// connection.
a.mu.Lock()
select {
case <-stop:
a.mu.Unlock()
return
default:
}
if result.LastID > 0 {
a.lastQID = result.LastID
}
a.mu.Unlock()
for i := range result.Messages {
a.handleServerMessage(&result.Messages[i])
}
+706 -9
View File
@@ -1,9 +1,13 @@
package cli
import (
"io"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
"github.com/gdamore/tcell/v2"
api "sneak.berlin/go/neoirc/internal/cli/api"
@@ -19,6 +23,24 @@ const (
testText = "hello"
)
// eventLoopTimeout is how long a test waits for a typed line to
// be handled, for the welcome lines, for the UI to stop, for a poll
// or for a poll loop to end, before it fails.
const eventLoopTimeout = 5 * time.Second
// holdTime is how long a test waits to see that something does not
// happen: the second line handled while the first is held, or a
// poll from a poll loop that was stopped.
const holdTime = 200 * time.Millisecond
// welcomeLines is the number of lines run adds to the status
// window when the client starts.
const welcomeLines = 2
// checkInterval is how often TestStartUp reads the status window
// while it waits for the welcome lines.
const checkInterval = 10 * time.Millisecond
func TestMemberPrefix(t *testing.T) {
t.Parallel()
@@ -138,11 +160,9 @@ func TestOwnCommandShownOnce(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
// The server accepts every command and answers nothing.
server := httptest.NewServer(http.HandlerFunc(
func(http.ResponseWriter, *http.Request) {},
))
t.Cleanup(server.Close)
// 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(),
@@ -166,6 +186,572 @@ func TestOwnCommandShownOnce(t *testing.T) {
}
}
// 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)
}
})
}
}
// TestAltDigit checks that Alt and a digit shows that window and
// makes its channel or user the target, as /window does. Window
// 0, the status window, leaves no target.
func TestAltDigit(t *testing.T) {
t.Parallel()
tests := []struct {
name string
digit rune
// The buffer shown and the target after the key.
wantBuffer string
wantTarget string
}{
{
name: "channel window",
digit: '1',
wantBuffer: testChannel,
wantTarget: testChannel,
},
{
name: "status window",
digit: '0',
wantBuffer: "(status)",
wantTarget: "",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
app := &App{ui: NewUI(), nick: testNick, target: testOtherNick}
handled := handledLines(app)
runUI(t, app.ui)
// Window 1 is the channel. Window 2, the one shown, is
// the other user's.
app.ui.SwitchToBuffer(testChannel)
app.ui.SwitchToBuffer(testOtherNick)
key := tcell.NewEventKey(tcell.KeyRune, test.digit, tcell.ModAlt)
app.ui.app.QueueEvent(key)
waitHandled(t, handled, "/window "+string(test.digit))
var shown string
app.ui.app.QueueUpdate(func() {
shown = app.ui.buffers[app.ui.currentBuffer].Name
})
if shown != test.wantBuffer || app.target != test.wantTarget {
t.Fatalf("got buffer %s and target %q, want %s and %q",
shown, app.target, test.wantBuffer, test.wantTarget)
}
})
}
}
// TestWindowConnectionState checks that typing /window 0, or
// pressing Alt and 0, leaves the status bar showing [connected]
// only while the client is connected, and [disconnected] otherwise.
func TestWindowConnectionState(t *testing.T) {
t.Parallel()
const line = "/window 0"
typeLine := func(ui *UI) {
for _, r := range line {
ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone))
}
ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone))
}
pressAltZero := func(ui *UI) {
ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyRune, '0', tcell.ModAlt))
}
tests := []struct {
name string
send func(ui *UI)
connected bool
want string
}{
{
name: "typed line, not connected", send: typeLine,
connected: false, want: "[disconnected]",
},
{
name: "Alt and 0, not connected", send: pressAltZero,
connected: false, want: "[disconnected]",
},
{
name: "typed line, connected", send: typeLine,
connected: true, want: "[connected]",
},
{
name: "Alt and 0, connected", send: pressAltZero,
connected: true, want: "[connected]",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
app := &App{ui: NewUI(), nick: testNick, connected: test.connected}
handled := handledLines(app)
runUI(t, app.ui)
test.send(app.ui)
waitHandled(t, handled, line)
status := statusBarText(app.ui)
if !strings.Contains(status, test.want) {
t.Fatalf("status bar is %q, want it to show %s", status, test.want)
}
})
}
}
// TestEventLoopKeepsRunning sends Alt and 1, then a typed line and
// Enter, to a running UI. It checks that both are handled, so the
// event loop did not stop on either.
func TestEventLoopKeepsRunning(t *testing.T) {
t.Parallel()
app := &App{ui: NewUI(), nick: testNick}
handled := handledLines(app)
runUI(t, app.ui)
// Window 1 is the channel, and window 0, the status window, is
// shown. The typed line adds a line to the status window, as
// the client is not connected.
app.ui.SwitchToBuffer(testChannel)
app.ui.SwitchBuffer(0)
app.ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyRune, '1', tcell.ModAlt))
for _, r := range testText {
app.ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone))
}
app.ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone))
waitHandled(t, handled, "/window 1")
waitHandled(t, handled, testText)
}
// TestLinesKeepOrder holds the handling of a first line until a
// second line has been passed in. It fails if the second line is
// handled while the first is still held.
func TestLinesKeepOrder(t *testing.T) {
t.Parallel()
const firstLine, secondLine = "one", "two"
ui := NewUI()
handled := make(chan string)
release := make(chan struct{})
ui.OnInput(func(text string) {
if text == firstLine {
<-release
}
handled <- text
})
ui.callOnInput(firstLine)
ui.callOnInput(secondLine)
select {
case got := <-handled:
t.Fatalf("handled %q while the first line was held", got)
case <-time.After(holdTime):
}
close(release)
waitHandled(t, handled, firstLine)
waitHandled(t, handled, secondLine)
}
// TestSecondConnectStopsFirstPollLoop connects to one server, then
// to a second that holds every poll. Only the second connection's
// poll loop may poll the second server.
func TestSecondConnectStopsFirstPollLoop(t *testing.T) {
t.Parallel()
const session = `{"id": 1, "nick": "erin"}`
// The first server answers every request at once, so the first
// poll loop polls it until the second /connect.
first := newServer(t, session)
second, polled := newHoldingServer(t, session)
app := &App{ui: NewUI(), nick: testNick}
runUI(t, app.ui)
app.cmdConnect(first.URL)
app.cmdConnect(second.URL)
waitPoll(t, polled, "the second server")
// The second connection's poll loop waits on its held poll, so
// another poll comes from the first loop.
select {
case <-polled:
t.Fatal("the first poll loop polled again after the second /connect")
case <-time.After(holdTime):
}
// Stop the second poll loop as /quit does. /quit itself also
// stops the UI, and runUI's cleanup stopping it again is a data
// race inside tview.
app.mu.Lock()
close(app.stopPoll)
app.mu.Unlock()
}
// TestSecondConnectIgnoresHeldPoll holds a poll of the earlier
// connection at its server while a second /connect runs, then
// answers it with a cursor and a message. It fails if that answer
// moves the cursor or shows the message.
func TestSecondConnectIgnoresHeldPoll(t *testing.T) {
t.Parallel()
const session = `{"id": 1, "nick": "erin"}`
const answer = `{"messages": [{"command": "PRIVMSG", "from": "bob",
"to": "#chan", "body": ["hello"]}], "last_id": 42}`
// The first server sends on polled when the poll arrives, and
// answers it once release is closed.
polled := make(chan struct{})
release := make(chan struct{})
first := httptest.NewServer(http.HandlerFunc(
func(writer http.ResponseWriter, request *http.Request) {
select {
case polled <- struct{}{}:
case <-request.Context().Done():
return
}
select {
case <-release:
_, _ = io.WriteString(writer, answer)
case <-request.Context().Done():
}
},
))
t.Cleanup(first.Close)
t.Cleanup(first.CloseClientConnections)
// The second server answers every request at once with the
// session. A poll answered with it has no cursor and no messages,
// so the second poll loop never moves the cursor.
second := newServer(t, session)
// The test runs the earlier connection's poll loop itself, so
// that it can wait for the loop to end.
stop := make(chan struct{})
app := &App{
ui: NewUI(),
client: api.NewClient(first.URL),
nick: testNick,
connected: true,
stopPoll: stop,
}
runUI(t, app.ui)
done := make(chan struct{})
go func() {
app.pollLoop(stop)
close(done)
}()
select {
case <-polled:
case <-time.After(eventLoopTimeout):
t.Fatalf("the first server got no poll within %v", eventLoopTimeout)
}
app.cmdConnect(second.URL)
close(release)
select {
case <-done:
case <-time.After(eventLoopTimeout):
t.Fatalf("the earlier poll loop did not end within %v", eventLoopTimeout)
}
app.mu.Lock()
cursor := app.lastQID
app.mu.Unlock()
if cursor != 0 {
t.Errorf("the earlier connection's answer moved the cursor to %d", cursor)
}
lines, _ := bufferState(app.ui, testChannel)
if lines != 0 {
t.Errorf("the earlier connection's message was shown in %s", testChannel)
}
// Stop the second poll loop as /quit does, without stopping the UI.
app.mu.Lock()
close(app.stopPoll)
app.mu.Unlock()
}
// TestSecondConnectAfterFailedPoll holds a poll of the earlier
// connection at its server while a second /connect runs, then
// fails it. A poll loop waits pollRetry after a failed poll, then
// polls again. The test fails if the earlier poll loop polls the
// second server.
func TestSecondConnectAfterFailedPoll(t *testing.T) {
t.Parallel()
const session = `{"id": 1, "nick": "erin"}`
first, firstPolled := newHoldingServer(t, session)
// The second server holds the one poll of the second
// connection's poll loop, so any other poll comes from the
// earlier loop.
second, secondPolled := newHoldingServer(t, session)
// The test runs the earlier connection's poll loop itself, so
// that it can wait for the loop to end.
stop := make(chan struct{})
app := &App{
ui: NewUI(),
client: api.NewClient(first.URL),
nick: testNick,
connected: true,
stopPoll: stop,
}
runUI(t, app.ui)
done := make(chan struct{})
go func() {
app.pollLoop(stop)
close(done)
}()
waitPoll(t, firstPolled, "the first server")
app.cmdConnect(second.URL)
waitPoll(t, secondPolled, "the second server")
// Closing the held poll's connection makes that poll fail.
first.CloseClientConnections()
select {
case <-done:
case <-secondPolled:
t.Fatal("the earlier poll loop polled the second server")
case <-time.After(eventLoopTimeout):
t.Fatalf("the earlier poll loop did not end within %v", eventLoopTimeout)
}
// Stop the second poll loop as /quit does, without stopping the UI.
app.mu.Lock()
close(app.stopPoll)
app.mu.Unlock()
}
// TestStartUp runs the client's start-up path on a simulation
// screen. It fails unless the event loop starts and the status
// window gets two lines: first the one that holds "Welcome to
// neoirc-cli", then the /connect hint. The status bar must show
// [disconnected].
func TestStartUp(t *testing.T) {
t.Parallel()
ui := NewUI()
ui.app.SetScreen(tcell.NewSimulationScreen(""))
// One slot, so that run's goroutine can end even after the
// cleanup below has stopped waiting for it.
done := make(chan error, 1)
go func() { done <- run(ui) }()
t.Cleanup(func() {
ui.Stop()
select {
case err := <-done:
if err != nil {
t.Errorf("run: %v", err)
}
case <-time.After(eventLoopTimeout):
t.Error("run did not return after the UI was stopped")
}
})
// run adds the welcome lines on a goroutine, so the status
// window is read until it holds two lines. Each read waits for
// the event loop, so the reads run on a goroutine too, which
// this test stops waiting for after eventLoopTimeout. The
// channel has one slot, so that goroutine can still end.
shown := make(chan []string, 1)
go func() {
for {
var lines []string
ui.app.QueueUpdate(func() {
lines = slices.Clone(ui.getOrCreateBuffer("(status)").Lines)
})
if len(lines) == welcomeLines {
shown <- lines
return
}
time.Sleep(checkInterval)
}
}()
var lines []string
select {
case lines = <-shown:
case <-time.After(eventLoopTimeout):
t.Fatalf("the status window did not get the welcome lines within %v",
eventLoopTimeout)
}
if !strings.Contains(lines[0], "Welcome to neoirc-cli") {
t.Errorf("first status line is %q, want the welcome line", lines[0])
}
if !strings.Contains(lines[1], "/connect <server-url>") {
t.Errorf("second status line is %q, want the /connect hint", lines[1])
}
// run sets the status bar before it adds the welcome lines.
status := statusBarText(ui)
if !strings.Contains(status, "[disconnected]") {
t.Errorf("status bar is %q, want it to show [disconnected]", status)
}
}
// TestStatusBar checks that the status bar shows [connected], and
// shows a nick, target or window name with square brackets as it
// is.
func TestStatusBar(t *testing.T) {
t.Parallel()
tests := []struct {
name string
nick string
target string
unread string
want string
}{
{
name: "plain names",
nick: testNick,
target: testChannel,
unread: testOtherNick,
want: " [connected] erin [1:#chan] #chan [Act: 2:bob(1)]",
},
{
name: "nicks with brackets",
nick: "erin[away]",
target: "bob[m]",
unread: "dave[afk]",
want: " [connected] erin[away] [1:bob[m]] bob[m] [Act: 2:dave[afk](1)]",
},
{
name: "nicks with brackets and symbols",
nick: "bob[|[]",
target: "x[^[]",
unread: testOtherNick,
want: " [connected] bob[|[] [1:x[^[]] x[^[] [Act: 2:bob(1)]",
},
{
// /query takes any text as the target.
name: "query target with brackets",
nick: testNick,
target: "[:::y|z]",
unread: testOtherNick,
want: " [connected] erin [1:[:::y|z]] [:::y|z] [Act: 2:bob(1)]",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
ui := NewUI()
runUI(t, ui)
// Window 1, the one shown, is the target's. Window 2
// gets one unread line.
ui.SwitchToBuffer(test.target)
ui.AddLine(test.unread, testText)
ui.SetStatus(test.nick, test.target, "connected")
got := statusBarText(ui)
if got != test.want {
t.Fatalf("status bar is %q, want %q", got, 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.
@@ -248,22 +834,133 @@ func bufferState(ui *UI, name string) (int, int) {
return lines, unread
}
// statusBarText returns the text the status bar shows.
// GetText(true) leaves out whatever tview reads as a tag, as
// drawing does.
func statusBarText(ui *UI) string {
var text string
ui.app.QueueUpdate(func() {
text = ui.statusBar.GetText(true)
})
return text
}
// handledLines makes app.ui pass each typed line to
// app.handleInput, as Run does, and returns a channel that
// receives each line once it has been handled.
func handledLines(app *App) <-chan string {
handled := make(chan string)
app.ui.OnInput(func(text string) {
app.handleInput(text)
handled <- text
})
return handled
}
// waitHandled fails the test unless want is the next line handled,
// within eventLoopTimeout.
func waitHandled(t *testing.T, handled <-chan string, want string) {
t.Helper()
select {
case got := <-handled:
if got != want {
t.Fatalf("handled %q, want %q", got, want)
}
case <-time.After(eventLoopTimeout):
t.Fatalf("%q was not handled within %v", want, eventLoopTimeout)
}
}
// 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
}
// newHoldingServer starts a server that sends on the returned
// channel for each poll and holds the poll until its connection
// closes. It answers any other request with body.
func newHoldingServer(
t *testing.T, body string,
) (*httptest.Server, <-chan struct{}) {
t.Helper()
polled := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(
func(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodGet ||
request.URL.Path != "/api/v1/messages" {
_, _ = io.WriteString(writer, body)
return
}
select {
case polled <- struct{}{}:
case <-request.Context().Done():
}
<-request.Context().Done()
},
))
// Close waits for the held polls, which end only once their
// connections close. Cleanups run last first, so the connections
// close before Close runs.
t.Cleanup(server.Close)
t.Cleanup(server.CloseClientConnections)
return server, polled
}
// waitPoll fails the test unless server gets a poll, sent on
// polled, within eventLoopTimeout.
func waitPoll(t *testing.T, polled <-chan struct{}, server string) {
t.Helper()
select {
case <-polled:
case <-time.After(eventLoopTimeout):
t.Fatalf("%s got no poll within %v", server, eventLoopTimeout)
}
}
// 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)
// One slot, so that Run's goroutine can end even after the
// cleanup below has stopped waiting for it.
done := make(chan error, 1)
go func() { done <- ui.Run() }()
t.Cleanup(func() {
ui.Stop()
err := <-done
if err != nil {
t.Errorf("run ui: %v", err)
select {
case err := <-done:
if err != nil {
t.Errorf("run ui: %v", err)
}
case <-time.After(eventLoopTimeout):
t.Error("the UI did not stop: its event loop stopped responding")
}
})
}
+59 -12
View File
@@ -16,7 +16,10 @@ type Buffer struct {
Unread int
}
// UI manages the terminal interface.
// UI manages the terminal interface. Most of its methods wait for
// the tview event loop to run their update. Code that runs on that
// loop, such as a key handler, must not call them: it would wait
// for itself.
type UI struct {
app *tview.Application
messages *tview.TextView
@@ -28,6 +31,10 @@ type UI struct {
currentBuffer int
onInput func(string)
// inputDone is closed once the line last passed to onInput
// has been handled. Only callOnInput, on the event loop, uses
// it.
inputDone chan struct{}
}
// NewUI creates the tview-based IRC-like UI.
@@ -176,9 +183,24 @@ func (ui *UI) SetStatus(
})
}
// BufferCount returns the number of buffers.
func (ui *UI) BufferCount() int {
return len(ui.buffers)
// BufferName returns the name of the buffer at index bufIndex,
// and whether there is such a buffer.
func (ui *UI) BufferName(bufIndex int) (string, bool) {
var (
name string
found bool
)
ui.app.QueueUpdate(func() {
if bufIndex < 0 || bufIndex >= len(ui.buffers) {
return
}
name = ui.buffers[bufIndex].Name
found = true
})
return name, found
}
// BufferIndex returns the index of a named buffer.
@@ -204,8 +226,10 @@ func (ui *UI) initMessages() {
}
func (ui *UI) initStatusBar() {
ui.statusBar = tview.NewTextView().
SetDynamicColors(true)
// Colour tags stay off. With them on, tview reads text in
// square brackets, such as "[connected]" or part of a nick,
// as a tag and drops it.
ui.statusBar = tview.NewTextView()
ui.statusBar.SetBackgroundColor(tcell.ColorNavy)
ui.statusBar.SetTextColor(tcell.ColorWhite)
}
@@ -226,10 +250,7 @@ func (ui *UI) initInput() {
}
ui.input.SetText("")
if ui.onInput != nil {
ui.onInput(text)
}
ui.callOnInput(text)
})
}
@@ -240,10 +261,11 @@ func (ui *UI) initKeyCapture() {
return event
}
// Alt and a digit runs /window with that digit.
// SwitchBuffer alone would leave the target as it was.
r := event.Rune()
if r >= '0' && r <= '9' {
idx := int(r - '0')
ui.SwitchBuffer(idx)
ui.callOnInput("/window " + string(r))
return nil
}
@@ -253,6 +275,31 @@ func (ui *UI) initKeyCapture() {
)
}
// callOnInput passes text to the input callback on a new
// goroutine. The key handlers call it on the event loop, and the
// callback waits for that loop to update the screen, so they must
// not wait for the callback. The goroutine first waits for the
// line before it to be handled, which keeps lines in the order
// they were typed.
func (ui *UI) callOnInput(text string) {
if ui.onInput == nil {
return
}
previous := ui.inputDone
done := make(chan struct{})
ui.inputDone = done
go func() {
if previous != nil {
<-previous
}
ui.onInput(text)
close(done)
}()
}
func (ui *UI) refreshStatusBar() {
// Placeholder; full refresh needs nick/target context.
}
+103 -7
View File
@@ -3,8 +3,12 @@ package config
import (
"errors"
"fmt"
"log/slog"
"math"
"os"
"strconv"
"time"
"github.com/spf13/viper"
"go.uber.org/fx"
@@ -28,6 +32,8 @@ Type /help for available commands.`
const DefaultDBURL = "file:///var/lib/neoirc/state.db" +
"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
var errOutOfRange = errors.New("setting out of range")
// Params defines the dependencies for creating a Config.
type Params struct {
fx.In
@@ -63,6 +69,8 @@ type Config struct {
}
// New creates a new Config by reading from files and environment variables.
// It returns an error when a number, true/false or duration setting does
// not parse or is out of range.
func New(
_ fx.Lifecycle, params Params,
) (*Config, error) {
@@ -107,29 +115,27 @@ func New(
cfg := &Config{
DBURL: viper.GetString("DBURL"),
Debug: viper.GetBool("DEBUG"),
Port: viper.GetInt("PORT"),
SentryDSN: viper.GetString("SENTRY_DSN"),
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
MessageMaxAge: viper.GetString("MESSAGE_MAX_AGE"),
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
QueueMaxAge: viper.GetString("QUEUE_MAX_AGE"),
MOTD: viper.GetString("MOTD"),
ServerName: viper.GetString("SERVER_NAME"),
FederationKey: viper.GetString("FEDERATION_KEY"),
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
OperName: viper.GetString("NEOIRC_OPER_NAME"),
OperPassword: viper.GetString("NEOIRC_OPER_PASSWORD"),
LoginRateLimit: viper.GetFloat64("LOGIN_RATE_LIMIT"),
LoginRateBurst: viper.GetInt("LOGIN_RATE_BURST"),
IRCListenAddr: viper.GetString("IRC_LISTEN_ADDR"),
log: log,
params: &params,
}
err = parseSettings(cfg)
if err != nil {
return nil, err
}
// Viper counts an empty environment variable as unset, but an
// empty IRC_LISTEN_ADDR must turn the IRC listener off.
ircListenAddr, set := os.LookupEnv("IRC_LISTEN_ADDR")
@@ -144,3 +150,93 @@ func New(
return cfg, nil
}
// parseSettings reads the number and true/false settings into cfg and
// checks the duration settings. Each number and duration must be above
// zero. PORT and NEOIRC_HASHCASH_BITS may also be zero: port 0 lets the
// system pick a port, and 0 bits turns proof of work off. The error
// names each setting that does not parse or is out of range.
func parseSettings(cfg *Config) error {
return errors.Join(
parseInt("PORT", 0, &cfg.Port),
parseInt("MAX_MESSAGE_SIZE", 1, &cfg.MaxMessageSize),
parseInt("NEOIRC_HASHCASH_BITS", 0, &cfg.HashcashBits),
parseInt("LOGIN_RATE_BURST", 1, &cfg.LoginRateBurst),
parseFloat("LOGIN_RATE_LIMIT", &cfg.LoginRateLimit),
parseBool("DEBUG", &cfg.Debug),
parseBool("MAINTENANCE_MODE", &cfg.MaintenanceMode),
checkDuration("MESSAGE_MAX_AGE", cfg.MessageMaxAge),
checkDuration("QUEUE_MAX_AGE", cfg.QueueMaxAge),
checkDuration("SESSION_IDLE_TIMEOUT", cfg.SessionIdleTimeout),
)
}
// parseInt sets *value to the setting key read as a whole number. It
// returns an error when the value does not parse or is below minimum.
func parseInt(key string, minimum int, value *int) error {
raw := viper.GetString(key)
parsed, err := strconv.Atoi(raw)
if err != nil {
return fmt.Errorf("cannot parse %s: %w", key, err)
}
if parsed < minimum {
return fmt.Errorf("%w: %s is %s, but must be at least %d",
errOutOfRange, key, raw, minimum)
}
*value = parsed
return nil
}
// parseFloat sets *value to the setting key read as a floating-point
// number. It returns an error when the value does not parse or is not a
// finite number above zero.
func parseFloat(key string, value *float64) error {
raw := viper.GetString(key)
parsed, err := strconv.ParseFloat(raw, 64)
if err != nil {
return fmt.Errorf("cannot parse %s: %w", key, err)
}
if math.IsNaN(parsed) || math.IsInf(parsed, 0) || parsed <= 0 {
return fmt.Errorf(
"%w: %s is %s, but must be a finite number above zero",
errOutOfRange, key, raw)
}
*value = parsed
return nil
}
// parseBool sets *value to the setting key read as true or false.
func parseBool(key string, value *bool) error {
parsed, err := strconv.ParseBool(viper.GetString(key))
if err != nil {
return fmt.Errorf("cannot parse %s: %w", key, err)
}
*value = parsed
return nil
}
// checkDuration returns an error naming the setting key when value is
// not a Go duration above zero.
func checkDuration(key, value string) error {
duration, err := time.ParseDuration(value)
if err != nil {
return fmt.Errorf("cannot parse %s: %w", key, err)
}
if duration <= 0 {
return fmt.Errorf("%w: %s is %s, but must be above zero",
errOutOfRange, key, value)
}
return nil
}
+149 -2
View File
@@ -2,6 +2,7 @@ package config_test
import (
"os"
"strings"
"testing"
"time"
@@ -10,7 +11,8 @@ import (
"sneak.berlin/go/neoirc/internal/logger"
)
func newConfig(t *testing.T) *config.Config {
// loadConfig runs config.New and returns its result.
func loadConfig(t *testing.T) (*config.Config, error) {
t.Helper()
globs := &globals.Globals{
@@ -24,9 +26,15 @@ func newConfig(t *testing.T) *config.Config {
t.Fatal(err)
}
cfg, err := config.New(
return config.New(
nil, config.Params{Globals: globs, Logger: log},
)
}
func newConfig(t *testing.T) *config.Config {
t.Helper()
cfg, err := loadConfig(t)
if err != nil {
t.Fatal(err)
}
@@ -34,6 +42,145 @@ func newConfig(t *testing.T) *config.Config {
return cfg
}
func TestUnsetSettingsKeepDefaults(t *testing.T) {
// Viper counts an empty environment variable as unset.
t.Setenv("PORT", "")
t.Setenv("MAX_MESSAGE_SIZE", "")
t.Setenv("NEOIRC_HASHCASH_BITS", "")
t.Setenv("LOGIN_RATE_BURST", "")
t.Setenv("LOGIN_RATE_LIMIT", "")
t.Setenv("DEBUG", "")
t.Setenv("MAINTENANCE_MODE", "")
t.Setenv("MESSAGE_MAX_AGE", "")
t.Setenv("QUEUE_MAX_AGE", "")
t.Setenv("SESSION_IDLE_TIMEOUT", "")
// Each duration setting defaults to 30 days.
const thirtyDays = "720h"
want := settings{
Port: 8080,
MaxMessageSize: 4096,
HashcashBits: 20,
LoginRateBurst: 5,
LoginRateLimit: 1,
Debug: false,
MaintenanceMode: false,
MessageMaxAge: thirtyDays,
QueueMaxAge: thirtyDays,
SessionIdleTimeout: thirtyDays,
}
got := settingsOf(newConfig(t))
if got != want {
t.Errorf("settings = %+v, want %+v", got, want)
}
}
func TestValidSettingsAreUsed(t *testing.T) {
// Each whole number is the lowest value its setting allows, and 1ns
// is the shortest duration allowed.
t.Setenv("PORT", "0")
t.Setenv("MAX_MESSAGE_SIZE", "1")
t.Setenv("NEOIRC_HASHCASH_BITS", "0")
t.Setenv("LOGIN_RATE_BURST", "1")
t.Setenv("LOGIN_RATE_LIMIT", "0.5")
t.Setenv("DEBUG", "true")
t.Setenv("MAINTENANCE_MODE", "true")
t.Setenv("MESSAGE_MAX_AGE", "1ns")
t.Setenv("QUEUE_MAX_AGE", "1h30m")
t.Setenv("SESSION_IDLE_TIMEOUT", "45m")
want := settings{
Port: 0,
MaxMessageSize: 1,
HashcashBits: 0,
LoginRateBurst: 1,
LoginRateLimit: 0.5,
Debug: true,
MaintenanceMode: true,
MessageMaxAge: "1ns",
QueueMaxAge: "1h30m",
SessionIdleTimeout: "45m",
}
got := settingsOf(newConfig(t))
if got != want {
t.Errorf("settings = %+v, want %+v", got, want)
}
}
// settings holds the Config fields whose settings config.New parses.
type settings struct {
Port int
MaxMessageSize int
HashcashBits int
LoginRateBurst int
LoginRateLimit float64
Debug bool
MaintenanceMode bool
MessageMaxAge string
QueueMaxAge string
SessionIdleTimeout string
}
func settingsOf(cfg *config.Config) settings {
return settings{
Port: cfg.Port,
MaxMessageSize: cfg.MaxMessageSize,
HashcashBits: cfg.HashcashBits,
LoginRateBurst: cfg.LoginRateBurst,
LoginRateLimit: cfg.LoginRateLimit,
Debug: cfg.Debug,
MaintenanceMode: cfg.MaintenanceMode,
MessageMaxAge: cfg.MessageMaxAge,
QueueMaxAge: cfg.QueueMaxAge,
SessionIdleTimeout: cfg.SessionIdleTimeout,
}
}
func TestBadSettingStopsStartup(t *testing.T) {
// For each setting, the first value does not parse and the others
// parse but are out of range.
tests := []struct {
key string
values []string
}{
{key: "PORT", values: []string{"80a", "-1"}},
{key: "MAX_MESSAGE_SIZE", values: []string{"4k", "0", "-4096"}},
{key: "NEOIRC_HASHCASH_BITS", values: []string{"twenty", "-1"}},
{key: "LOGIN_RATE_BURST", values: []string{"five", "0", "-5"}},
{key: "LOGIN_RATE_LIMIT", values: []string{
"fast", "0", "-1", "NaN", "Inf", "-Inf",
}},
{key: "DEBUG", values: []string{"yes"}},
{key: "MAINTENANCE_MODE", values: []string{"on"}},
{key: "MESSAGE_MAX_AGE", values: []string{"30d", "0", "-1h"}},
{key: "QUEUE_MAX_AGE", values: []string{"720", "0s", "-24h"}},
{key: "SESSION_IDLE_TIMEOUT", values: []string{"a month", "0", "-1m"}},
}
for _, test := range tests {
for _, value := range test.values {
t.Run(test.key+"="+value, func(t *testing.T) {
t.Setenv(test.key, value)
_, err := loadConfig(t)
if err == nil {
t.Fatalf("%s=%q: got no error", test.key, value)
}
msg := err.Error()
if !strings.Contains(msg, test.key) ||
!strings.Contains(msg, value) {
t.Errorf("error %q does not name %s and %q",
msg, test.key, value)
}
})
}
}
}
func TestIRCListenAddrUnsetKeepsDefault(t *testing.T) {
// t.Setenv puts back whatever value was there after the test.
t.Setenv("IRC_LISTEN_ADDR", "")
+33 -7
View File
@@ -5,9 +5,11 @@ import (
"context"
"database/sql"
"embed"
"errors"
"fmt"
"io/fs"
"log/slog"
"net/url"
"sort"
"strconv"
"strings"
@@ -22,6 +24,10 @@ 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
@@ -90,6 +96,33 @@ 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,
)
@@ -109,13 +142,6 @@ 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,19 +1,34 @@
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.
@@ -73,3 +88,191 @@ 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
}
+6 -11
View File
@@ -5,21 +5,16 @@ import (
"database/sql"
"fmt"
"log/slog"
"sync/atomic"
"testing"
"sneak.berlin/go/neoirc/internal/dbtest"
)
//nolint:gochecknoglobals // test counter
var testDBCounter atomic.Int64
// NewTestDatabase creates an in-memory database for testing.
func NewTestDatabase() (*Database, error) {
counter := testDBCounter.Add(1)
func NewTestDatabase(tb testing.TB) (*Database, error) {
tb.Helper()
dsn := fmt.Sprintf(
"file:testdb%d?mode=memory"+
"&cache=shared&_pragma=foreign_keys(1)",
counter,
)
dsn := dbtest.NewURL(tb) + "&_pragma=foreign_keys(1)"
conn, err := sql.Open("sqlite", dsn)
if err != nil {
+1 -1
View File
@@ -13,7 +13,7 @@ import (
func setupTestDB(t *testing.T) *db.Database {
t.Helper()
database, err := db.NewTestDatabase()
database, err := db.NewTestDatabase(t)
if err != nil {
t.Fatal(err)
}
+39
View File
@@ -0,0 +1,39 @@
// 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
}
+1 -1
View File
@@ -412,7 +412,7 @@ func (hdlr *Handlers) deliverWelcome(
// 004 RPL_MYINFO
hdlr.enqueueNumeric(
ctx, clientID, irc.RplMyInfo, nick,
[]string{srvName, version, "", "ikmnostl"},
[]string{srvName, version, service.UserModes, "Hbiklmnostv"},
"",
)
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -1,4 +1,3 @@
//nolint:paralleltest // global viper, as in api_test.go
package handlers_test
import (
@@ -72,6 +71,8 @@ func numericBody(
}
func TestUserhost(t *testing.T) {
t.Parallel()
tserver := newTestServer(t)
token := tserver.createSession("alice")
@@ -109,6 +110,8 @@ func TestUserhost(t *testing.T) {
}
func TestVersionAdminInfoTime(t *testing.T) {
t.Parallel()
tserver := newTestServer(t)
token := tserver.createSession("frank")
@@ -147,6 +150,8 @@ func TestVersionAdminInfoTime(t *testing.T) {
}
func TestKill(t *testing.T) {
t.Parallel()
tserver := newTestServerWithOper(t)
victimToken := tserver.createSession("victim")
@@ -213,6 +218,8 @@ func TestKill(t *testing.T) {
}
func TestWallops(t *testing.T) {
t.Parallel()
tserver := newTestServerWithOper(t)
receiverToken := tserver.createSession("receiver")
@@ -258,6 +265,8 @@ func TestWallops(t *testing.T) {
}
func TestUserMode(t *testing.T) {
t.Parallel()
const (
nick = "alice"
other = "other"
+6 -2
View File
@@ -371,6 +371,7 @@ func (c *Conn) handleQuit(msg *Message) {
c.mu.Lock()
c.closed = true
c.quitReason = reason
c.mu.Unlock()
}
@@ -1226,9 +1227,12 @@ func (c *Conn) handlePassPostReg(
return
}
// The IRC listener cannot add a connection to an existing
// session; only the HTTP API login can.
c.sendFromServer("NOTICE", c.currentNick(),
"Password set. You can reconnect using "+
"PASS <password> with your nick.",
"Password set. Another client can log in to this "+
"session over the HTTP API: POST /api/v1/login "+
"with your nick and this password.",
)
}
+17 -12
View File
@@ -69,6 +69,11 @@ type Conn struct {
lastQueueID int64
closed bool
killed bool
// quitReason is the reason in the QUIT that peers are
// sent when the connection ends. The client's QUIT
// replaces it.
quitReason string
}
func newConn(
@@ -87,14 +92,15 @@ func newConn(
}
conn := &Conn{ //nolint:exhaustruct // zero-value defaults
conn: tcpConn,
log: log,
database: database,
brk: brk,
cfg: cfg,
svc: svc,
serverSfx: srvName,
remoteIP: host,
conn: tcpConn,
log: log,
database: database,
brk: brk,
cfg: cfg,
svc: svc,
serverSfx: srvName,
remoteIP: host,
quitReason: "Connection closed",
}
conn.commands = conn.buildCommandMap()
@@ -278,6 +284,7 @@ func (c *Conn) cleanup(ctx context.Context) {
wasKilled := c.killed
sessID := c.sessionID
nick := c.nick
quitReason := c.quitReason
c.closed = true
c.mu.Unlock()
@@ -287,9 +294,7 @@ func (c *Conn) cleanup(ctx context.Context) {
// KILL has already sent the QUIT and deleted the
// session.
if !wasKilled {
c.svc.BroadcastQuit(
ctx, sessID, nick, "Connection closed",
)
c.svc.BroadcastQuit(ctx, sessID, nick, quitReason)
}
}
@@ -605,7 +610,7 @@ func (c *Conn) deliverWelcome() {
)
c.sendNumeric(
irc.RplMyInfo,
c.serverSfx, "neoirc", "", "mnst",
c.serverSfx, "neoirc", service.UserModes, "Himnostv",
)
c.sendNumeric(
irc.RplIsupport,
+6 -2
View File
@@ -32,8 +32,12 @@ func (c *Conn) relayMessages(ctx context.Context) {
// still signals waitCh.
waitCh := c.brk.Wait(c.sessionID)
// Drain any available messages.
delivered := c.drainQueue(ctx)
// 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))
if delivered {
c.brk.Remove(c.sessionID, waitCh)
+1 -1
View File
@@ -316,7 +316,7 @@ func TestRelayNickDiffersOnlyInCase(t *testing.T) {
{"JOIN #case", ":Alice!Alice@* JOIN #case"},
{"NICK Alicia", ":Alice!Alice@* NICK Alicia"},
{"NICK Alice", ":Alicia!Alicia@* NICK Alice"}, // back for QUIT
{"QUIT", ":Alice!Alice@* QUIT :Quit: Connection closed"},
{"QUIT", ":Alice!Alice@* QUIT :Quit: Client quit"},
} {
capitalAlice.send(step.line)
+138 -25
View File
@@ -3,14 +3,15 @@ package ircserver_test
import (
"bufio"
"context"
"crypto/rand"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"os"
"runtime"
"slices"
"strings"
"sync/atomic"
"testing"
@@ -20,9 +21,11 @@ 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"
"sneak.berlin/go/neoirc/pkg/irc"
_ "modernc.org/sqlite"
)
@@ -107,14 +110,7 @@ func newTestEnvWithResolver(
) *testEnv {
t.Helper()
// 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)
conn, err := sql.Open("sqlite", dbtest.NewURL(t))
if err != nil {
t.Fatalf("open db: %v", err)
}
@@ -144,20 +140,6 @@ 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
@@ -167,7 +149,9 @@ func newTestEnvWithResolver(
log, cfg, database, brk, interval, deadline, resolve,
)
err = srv.Start(addr)
// 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")
if err != nil {
t.Fatalf("start irc server: %v", err)
}
@@ -505,6 +489,22 @@ func TestWelcomeContainsNick(t *testing.T) {
}
}
// TestMyInfoListsModes checks that RPL_MYINFO lists the user
// modes o and w, then the channel modes MODE accepts on the
// IRC listener. It compares the whole line, so a missing or
// extra mode fails it.
func TestMyInfoListsModes(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
lines := env.dial(t).register("carol")
want := ":test.irc 004 carol test.irc neoirc ow Himnostv"
if !slices.Contains(lines, want) {
t.Errorf("no line %q in %v", want, lines)
}
}
func TestPingPong(t *testing.T) {
t.Parallel()
@@ -875,6 +875,112 @@ func TestQuit(t *testing.T) {
assertContains(t, lines, "goodbye", "QUIT reason")
}
// TestQuitReasonReachesPeers checks the reason that an IRC
// listener user's channel peers see in its QUIT, on both the
// IRC listener and the HTTP API. It is the reason the user
// gave, Client quit for a QUIT without one, or Connection
// closed when the connection ends without a QUIT.
func TestQuitReasonReachesPeers(t *testing.T) {
t.Parallel()
for _, step := range []struct {
name, quit, want string
}{
{"QUIT with a reason", "QUIT :gone home", "gone home"},
{"QUIT without a reason", irc.CmdQuit, "Client quit"},
{"connection closed without QUIT", "", "Connection closed"},
} {
t.Run(step.name, func(t *testing.T) {
t.Parallel()
ircLine, apiBody := quitSeenByPeers(t, step.quit)
want := ":quitter!quitter@* QUIT :Quit: " + step.want
if ircLine != want {
t.Errorf("IRC peer got %q, want %q", ircLine, want)
}
if len(apiBody) != 1 || apiBody[0] != step.want {
t.Errorf("HTTP API peer got %q, want [%q]", apiBody, step.want)
}
})
}
}
// quitSeenByPeers starts a server on which quitter, ircpeer on
// the IRC listener and apipeer on the HTTP API share a channel.
// quitter sends the line quit, or closes its connection when
// quit is empty. It returns the QUIT line ircpeer is sent and
// the body of the QUIT apipeer is sent.
func quitSeenByPeers(t *testing.T, quit string) (string, []string) {
t.Helper()
env := newTestEnv(t)
ctx := t.Context()
ircPeer := env.dial(t)
ircPeer.register("ircpeer")
ircPeer.joinAndDrain("#bye")
apiPeerID, apiPeerClientID, _, err := env.database.CreateSession(
ctx, "apipeer", "apipeer", "", "",
)
if err != nil {
t.Fatalf("create peer session: %v", err)
}
channelID, err := env.database.GetChannelByName(ctx, "#bye")
if err != nil {
t.Fatalf("get channel: %v", err)
}
err = env.database.JoinChannel(ctx, channelID, apiPeerID)
if err != nil {
t.Fatalf("join peer: %v", err)
}
quitter := env.dial(t)
quitter.register("quitter")
quitter.joinAndDrain("#bye")
if quit == "" {
err = quitter.conn.Close()
if err != nil {
t.Fatalf("close: %v", err)
}
} else {
quitter.send(quit)
}
lines := ircPeer.readUntil(func(l string) bool {
return strings.Contains(l, " QUIT ")
})
// The QUIT is queued to both peers at once, so apipeer has
// it by now.
msgs, _, err := env.database.PollMessages(ctx, apiPeerClientID, 0, 0)
if err != nil {
t.Fatalf("poll peer: %v", err)
}
for _, msg := range msgs {
if msg.Command == irc.CmdQuit && msg.From == "quitter" {
var body []string
err = json.Unmarshal(msg.Body, &body)
if err != nil {
t.Fatalf("decode QUIT body %s: %v", msg.Body, err)
}
return lines[len(lines)-1], body
}
}
t.Fatalf("apipeer was not sent the QUIT: %v", msgs)
return "", nil
}
// TestStopEndsSessions checks that stopping the server ends
// a registered connection's session before Stop returns, as
// closing the connection does: the session, its client and
@@ -1144,6 +1250,9 @@ func TestAwaySetAndClear(t *testing.T) {
assertContains(t, clearLines, " 305 ", "RPL_UNAWAY")
}
// TestHandlePassPostRegistration checks the NOTICE that
// confirms PASS after registration. It points to the HTTP API
// login, the only way to add a client to an existing session.
func TestHandlePassPostRegistration(t *testing.T) {
t.Parallel()
@@ -1156,7 +1265,11 @@ func TestHandlePassPostRegistration(t *testing.T) {
)
assertContains(
t, lines, "Password set", "password confirmation",
t, lines,
" NOTICE passuser :Password set. Another client can log "+
"in to this session over the HTTP API: "+
"POST /api/v1/login with your nick and this password.",
"password confirmation",
)
}
+6
View File
@@ -15,6 +15,12 @@ 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,6 +50,7 @@ type Server struct {
sentryEnabled bool
log *slog.Logger
httpServer *http.Server
listener net.Listener
router *chi.Mux
params Params
mw *middleware.Middleware
@@ -179,6 +180,8 @@ 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,
+15 -48
View File
@@ -5,7 +5,6 @@
package server_test
import (
"crypto/rand"
"fmt"
"net"
"net/http"
@@ -19,6 +18,7 @@ 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,10 +45,9 @@ const (
// for a long poll the HTTP server is serving to finish, and
// that the poll gets its response.
func TestStopWaitsForRequestInProgress(t *testing.T) {
port := freePort(t)
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
var srv *server.Server
app, brk, database := startApp(t, port)
app, brk, database := startApp(t, fx.Populate(&srv))
sessionID, _, token, err := database.CreateSession(
t.Context(), "poller", "poller", "", "",
@@ -57,9 +56,12 @@ func TestStopWaitsForRequestInProgress(t *testing.T) {
t.Fatalf("create session: %v", err)
}
waitUntil(t, "the HTTP server to listen", func() bool {
return canConnect(t, addr)
})
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))
statuses := make(chan int, 1)
@@ -145,7 +147,7 @@ func TestStartFailsWhenHTTPPortIsTaken(t *testing.T) {
func TestServeFailureStopsApp(t *testing.T) {
var srv *server.Server
app, _, _ := startApp(t, freePort(t), fx.Populate(&srv))
app, _, _ := startApp(t, fx.Populate(&srv))
shutdowns := app.Wait()
@@ -168,7 +170,7 @@ func TestServeFailureStopsApp(t *testing.T) {
func TestServeAfterStopDoesNotStopApp(t *testing.T) {
var srv *server.Server
app, _, _ := startApp(t, freePort(t), fx.Populate(&srv))
app, _, _ := startApp(t, fx.Populate(&srv))
shutdowns := app.Wait()
@@ -186,16 +188,15 @@ func TestServeAfterStopDoesNotStopApp(t *testing.T) {
}
}
// startApp starts the app newApp builds, with the HTTP server
// listening on port.
// startApp starts the app newApp builds, with the HTTP port
// set to 0, so the system gives the server a free port.
func startApp(
t *testing.T,
port int,
options ...fx.Option,
) (*fxtest.App, *broker.Broker, *db.Database) {
t.Helper()
app, brk, database := newApp(t, port, options...)
app, brk, database := newApp(t, 0, options...)
app.RequireStart()
@@ -241,10 +242,7 @@ func newApp(
stats.New,
),
fx.Decorate(func(cfg *config.Config) *config.Config {
// A random name, so that no other test can share it.
cfg.DBURL = fmt.Sprintf(
"file:%s?mode=memory&cache=shared", rand.Text(),
)
cfg.DBURL = dbtest.NewURL(t)
cfg.Port = port
return cfg
@@ -257,21 +255,6 @@ 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 {
@@ -294,22 +277,6 @@ 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) {
+6
View File
@@ -1169,6 +1169,12 @@ func (s *Service) ApplyUserMode(
return s.QueryUserMode(ctx, sessionID)
}
// UserModes lists the user modes the server supports, as
// RPL_MYINFO sends them: o (server operator) and w
// (receives WALLOPS). parseUserModeString must accept the
// same letters.
const UserModes = "ow"
// parseUserModeString parses a user mode string such as
// "+w", "-o" or "+w-o" into the new value of each flag it
// names, or nil for a flag it does not name; a later letter
+2 -8
View File
@@ -6,7 +6,6 @@ package service_test
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
@@ -23,6 +22,7 @@ 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,13 +46,7 @@ type testEnv struct {
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
// 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(),
)
dbURL := dbtest.NewURL(t)
var (
database *db.Database
+85 -82
View File
@@ -1,98 +1,101 @@
# Message Schemas
# Message schemas
JSON Schema definitions (draft 2020-12) for the neoirc protocol. Messages use
**IRC command names and numeric reply codes** (RFC 1459/2812) encoded as JSON
over HTTP.
This directory holds JSON Schema files (draft 2020-12) for the messages that the
neoirc HTTP API carries. `message.json` describes the fields that every message
can have. `commands/` has files for some commands. `numerics/` has files for
some of the numeric replies. A numeric reply is a server reply named by a
three-digit number, such as `001`. `README.md` at the repository root documents
the protocol.
## Envelope
The server can also run an IRC listener for ordinary IRC clients. The IRC
listener sends plain IRC lines, which these files do not describe.
Every message is a JSON object with a `command` field. The format maps directly
to IRC wire format:
## Message fields
Every message is one JSON object, which `message.json` calls the envelope. These
are the fields it can have:
- `command`: the IRC command name, such as `PRIVMSG`, or the name of a numeric
reply, such as `RPL_WELCOME`. Always present.
- `code`: the number of a numeric reply, such as `1` for `RPL_WELCOME`. Absent
on other messages.
- `id`: the message's UUID, a unique ID that the server assigns.
- `from`: the sender's nick, or the server name on a numeric reply or a `PONG`.
- `to`: the target, such as a channel name starting with `#` or a nick.
- `params`: an array of strings with the message's other parameters, in order.
`KICK` events and many numeric replies have it. The HTTP API ignores `params`
sent by a client.
- `body`: an array of strings. It is never a bare string. In a `PRIVMSG` or
`NOTICE`, each string is one line of text.
- `ts`: the time the server stored the message. It is an RFC 3339 time in UTC,
such as `2026-10-27T12:00:00.123Z`.
- `meta`: an object of extra data, such as a hashcash stamp in `meta.hashcash`.
A hashcash stamp is a short string that takes a computer some work to make. A
`PRIVMSG` to a channel with mode `+H` needs one. The HTTP API relays the
`meta` of a `PRIVMSG` or `NOTICE` unchanged. It drops the `meta` of every
other command.
Here is one `PRIVMSG` as the IRC listener sends it and as the HTTP API sends it.
The JSON leaves out `id`, `ts` and `meta`:
```
IRC: :nick PRIVMSG #channel :hello world
JSON: {"command": "PRIVMSG", "from": "nick", "to": "#channel", "body": ["hello world"]}
IRC: :server 353 nick = #channel :user1 @op1 +voice1
JSON: {"command": "353", "to": "nick", "params": ["=", "#channel"], "body": ["user1 @op1 +voice1"]}
Multiline: {"command": "PRIVMSG", "to": "#ch", "body": ["line 1", "line 2"]}
Structured: {"command": "PUBKEY", "body": {"alg": "ed25519", "key": "base64..."}}
IRC: :alice!alice@* PRIVMSG #general :hello world
JSON: {"command": "PRIVMSG", "from": "alice", "to": "#general", "body": ["hello world"]}
```
Common fields (see `message.json` for full schema):
In the JSON, a numeric reply has its name in `command` and its number in `code`.
This `353` reply lists the members of `#general`. The JSON again leaves out
`id`, `ts` and `meta`:
| Field | Type | Description |
| --------- | --------------- | ---------------------------------------------------- |
| `id` | string (uuid) | Server-assigned message UUID |
| `command` | string | IRC command or 3-digit numeric code |
| `from` | string | Source nick or server name (IRC prefix) |
| `to` | string | Target: #channel or nick |
| `params` | string[] | Middle parameters (mainly for numerics) |
| `body` | array \| object | Structured body — never a raw string (see below) |
| `ts` | string | ISO 8601 timestamp (server-assigned, not in raw IRC) |
| `meta` | object | Extensible metadata (signatures, hashes, etc.) |
```
{"command": "RPL_NAMREPLY", "code": 353, "from": "neoirc", "to": "alice",
"params": ["=", "#general"], "body": ["@op1!op1@host1 alice!alice@host2"]}
```
**Structured bodies:** `body` is always an array of strings (for text) or an
object (for structured data like PUBKEY). Never a raw string. This enables:
A `PRIVMSG` whose `body` has two strings is a two-line message:
- Multiline messages without escape sequences
- Deterministic canonicalization via RFC 8785 JCS for signing
- Structured data where needed
```
{"command": "PRIVMSG", "to": "#general", "body": ["line one", "line two"]}
```
## Commands
## Files
IRC commands used for client↔server and server↔server communication.
Most command files describe both the message a client sends and the message the
server sends. Their field descriptions say where the two differ. The examples in
the files leave out `id`, `ts` and `meta`.
| Command | File | RFC | Description |
| --------- | ----------------------- | ----------- | -------------------------- |
| `PRIVMSG` | `commands/PRIVMSG.json` | 1459 §4.4.1 | Message to channel or user |
| `NOTICE` | `commands/NOTICE.json` | 1459 §4.4.2 | Notice (no auto-reply) |
| `JOIN` | `commands/JOIN.json` | 1459 §4.2.1 | Join a channel |
| `PART` | `commands/PART.json` | 1459 §4.2.2 | Leave a channel |
| `QUIT` | `commands/QUIT.json` | 1459 §4.1.6 | User disconnected |
| `NICK` | `commands/NICK.json` | 1459 §4.1.2 | Change nickname |
| `TOPIC` | `commands/TOPIC.json` | 1459 §4.2.4 | Get/set channel topic |
| `MODE` | `commands/MODE.json` | 1459 §4.2.3 | Set channel/user modes |
| `KICK` | `commands/KICK.json` | 1459 §4.2.8 | Kick user from channel |
| `PING` | `commands/PING.json` | 1459 §4.6.2 | Keepalive |
| `PONG` | `commands/PONG.json` | 1459 §4.6.3 | Keepalive response |
| `PUBKEY` | `commands/PUBKEY.json` | (extension) | Announce/relay signing key |
Commands, in `commands/`:
## Numeric Replies
- `PRIVMSG.json`: a message to a channel or a user.
- `NOTICE.json`: a notice, which is a message that a client must not answer
automatically.
- `JOIN.json`: joining a channel.
- `PART.json`: leaving a channel.
- `QUIT.json`: leaving the server.
- `NICK.json`: changing nick.
- `TOPIC.json`: setting a channel's topic.
- `MODE.json`: querying or changing a channel's modes or the user's own modes.
- `KICK.json`: a channel operator removing a user from a channel.
- `PING.json`: a check that the server still answers.
- `PONG.json`: the answer to a `PING`, which is the HTTP response to it.
Three-digit codes for server responses, per IRC convention.
Numeric replies, in `numerics/`:
### Success / Informational (0xx–3xx)
| Code | Name | File | Description |
| ----- | -------------- | ------------------- | ------------------------------ |
| `001` | RPL_WELCOME | `numerics/001.json` | Welcome after session creation |
| `002` | RPL_YOURHOST | `numerics/002.json` | Server host info |
| `003` | RPL_CREATED | `numerics/003.json` | Server creation date |
| `004` | RPL_MYINFO | `numerics/004.json` | Server info and modes |
| `322` | RPL_LIST | `numerics/322.json` | Channel list entry |
| `323` | RPL_LISTEND | `numerics/323.json` | End of channel list |
| `332` | RPL_TOPIC | `numerics/332.json` | Channel topic |
| `353` | RPL_NAMREPLY | `numerics/353.json` | Channel member list |
| `366` | RPL_ENDOFNAMES | `numerics/366.json` | End of NAMES list |
| `372` | RPL_MOTD | `numerics/372.json` | MOTD line |
| `375` | RPL_MOTDSTART | `numerics/375.json` | Start of MOTD |
| `376` | RPL_ENDOFMOTD | `numerics/376.json` | End of MOTD |
### Errors (4xx)
| Code | Name | File | Description |
| ----- | -------------------- | ------------------- | ----------------------- |
| `401` | ERR_NOSUCHNICK | `numerics/401.json` | No such nick/channel |
| `403` | ERR_NOSUCHCHANNEL | `numerics/403.json` | No such channel |
| `433` | ERR_NICKNAMEINUSE | `numerics/433.json` | Nickname already in use |
| `442` | ERR_NOTONCHANNEL | `numerics/442.json` | Not on that channel |
| `482` | ERR_CHANOPRIVSNEEDED | `numerics/482.json` | Not channel operator |
## Federation (S2S)
Server-to-server messages use the same command format. Federated servers relay
messages with an additional `origin` field in `meta` to track the source server.
The PING/PONG commands serve as inter-server keepalives. State sync after link
establishment uses a burst of JOIN, NICK, TOPIC, and MODE commands.
- `001.json`: `RPL_WELCOME`, which welcomes the user.
- `002.json`: `RPL_YOURHOST`, the server's name and version.
- `003.json`: `RPL_CREATED`, the date the server last started.
- `004.json`: `RPL_MYINFO`, the server's name, its version and its lists of
modes.
- `322.json`: `RPL_LIST`, one channel in the answer to `LIST`.
- `323.json`: `RPL_LISTEND`, the end of the answer to `LIST`.
- `332.json`: `RPL_TOPIC`, a channel's topic.
- `353.json`: `RPL_NAMREPLY`, a channel's members.
- `366.json`: `RPL_ENDOFNAMES`, the end of the member list.
- `372.json`: `RPL_MOTD`, one line of the message of the day.
- `375.json`: `RPL_MOTDSTART`, the start of the message of the day.
- `376.json`: `RPL_ENDOFMOTD`, the end of the message of the day.
- `401.json`: `ERR_NOSUCHNICK`, the nick does not exist.
- `403.json`: `ERR_NOSUCHCHANNEL`, the channel does not exist.
- `433.json`: `ERR_NICKNAMEINUSE`, the nick is taken.
- `442.json`: `ERR_NOTONCHANNEL`, the user is not on that channel.
- `482.json`: `ERR_CHANOPRIVSNEEDED`, the user is not a channel operator.
+14 -4
View File
@@ -2,15 +2,25 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/JOIN.json",
"title": "JOIN",
"description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.",
"description": "Join a channel. A client sends it to join a channel, which the server creates if it does not exist. The server sends it to every member of the channel when a user joins, the new member included. RFC 1459 §4.2.1.",
"$ref": "../message.json",
"properties": {
"command": { "const": "JOIN" },
"from": { "type": "string", "description": "Nick that joined (S2C)." },
"to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" }
"from": { "type": "string", "description": "The nick that joined. The server sets it." },
"to": {
"type": "string",
"description": "The channel name: # and then 1 to 63 of a-z, A-Z, 0-9, _ and -. The server adds the # if a client leaves it out.",
"pattern": "^#?[a-zA-Z0-9_-]{1,63}$"
},
"body": {
"type": "array",
"items": { "type": "string" },
"description": "From a client: the channel key as the first string, which a channel with mode +k needs. From the server: the channel name. After a login, and for GET /api/v1/state?initChannelState=1, the server queues a JOIN for each of the session's channels. That JOIN has an empty body."
}
},
"required": ["command", "to"],
"examples": [
{ "command": "JOIN", "from": "alice", "to": "#general" }
{ "command": "JOIN", "to": "#general" },
{ "command": "JOIN", "from": "alice", "to": "#general", "body": ["#general"] }
]
}
+12 -7
View File
@@ -2,28 +2,33 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/KICK.json",
"title": "KICK",
"description": "Kick a user from a channel. RFC 1459 §4.2.8.",
"description": "Remove a user from a channel. A client sends it, and only a channel operator may. The server sends it to every member of the channel, the removed user included. RFC 1459 §4.2.8.",
"$ref": "../message.json",
"properties": {
"command": { "const": "KICK" },
"from": { "type": "string", "description": "Nick that performed the kick." },
"to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" },
"from": { "type": "string", "description": "The nick that removed the user. The server sets it." },
"to": {
"type": "string",
"description": "The channel name. The server adds the # if a client leaves it out.",
"pattern": "^#?[a-zA-Z0-9_-]{1,63}$"
},
"params": {
"type": "array",
"items": { "type": "string" },
"description": "Kicked nick. e.g. [\"alice\"].",
"description": "The removed nick. Only the server sends it.",
"minItems": 1,
"maxItems": 1
},
"body": {
"type": "array",
"items": { "type": "string" },
"description": "Optional kick reason.",
"maxItems": 1
"description": "From a client: the nick to remove, then an optional reason. From the server: the reason, as one string.",
"minItems": 1
}
},
"required": ["command", "to", "params"],
"required": ["command", "to", "body"],
"examples": [
{ "command": "KICK", "to": "#general", "body": ["troll", "Behave"] },
{ "command": "KICK", "from": "op1", "to": "#general", "params": ["troll"], "body": ["Behave"] }
]
}
+10 -9
View File
@@ -2,28 +2,29 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/MODE.json",
"title": "MODE",
"description": "Set or query channel/user modes. RFC 1459 §4.2.3.",
"description": "Query or change a channel's modes or the user's own modes. A client sends it. The server sends it to every member of the channel when a channel mode changes. A +H or -H sent over the HTTP API is the exception. The server answers it with RPL_CHANNELMODEIS (324) and sends no MODE. RFC 1459 §4.2.3.",
"$ref": "../message.json",
"properties": {
"command": { "const": "MODE" },
"from": {
"type": "string",
"description": "Nick that set the mode (S2C only)."
"description": "The nick that changed the mode. The server sets it."
},
"to": {
"type": "string",
"description": "Channel name.",
"pattern": "^#[a-zA-Z0-9_-]+$"
"description": "A channel name, which starts with #, or the user's own nick."
},
"params": {
"body": {
"type": "array",
"items": { "type": "string" },
"description": "Mode string and optional target nick. e.g. [\"+o\", \"alice\"].",
"examples": [["+o", "alice"], ["-m"], ["+v", "bob"]]
"description": "From a client: the mode change and then its value, as separate strings, such as \"+o\" and \"bob\". A MODE without a body is a query. From the server: the change and its value, as one string, such as \"+o bob\", or as separate strings, such as \"+k\" and \"secret\".",
"examples": [["+o", "bob"], ["-m"], ["+o bob"], ["+k", "secret"]]
}
},
"required": ["command", "to", "params"],
"required": ["command", "to"],
"examples": [
{ "command": "MODE", "from": "op1", "to": "#general", "params": ["+o", "alice"] }
{ "command": "MODE", "to": "#general", "body": ["+o", "bob"] },
{ "command": "MODE", "from": "op1", "to": "#general", "body": ["+o bob"] },
{ "command": "MODE", "from": "op1", "to": "#general", "body": ["+k", "secret"] }
]
}
+5 -5
View File
@@ -2,21 +2,21 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/NICK.json",
"title": "NICK",
"description": "Change nickname. C2S: request new nick. S2C: notification of nick change. RFC 1459 §4.1.2.",
"description": "Change nick. A client sends it to change its nick. The server sends it to the user's own clients and to the users who share a channel with the user. RFC 1459 §4.1.2.",
"$ref": "../message.json",
"properties": {
"command": { "const": "NICK" },
"from": { "type": "string", "description": "Old nick (S2C)." },
"from": { "type": "string", "description": "The old nick. The server sets it." },
"body": {
"type": "array",
"items": { "type": "string" },
"description": "New nick (single-element array).",
"minItems": 1,
"maxItems": 1
"description": "The new nick, as the first string. The server sends one string.",
"minItems": 1
}
},
"required": ["command", "body"],
"examples": [
{ "command": "NICK", "body": ["newnick"] },
{ "command": "NICK", "from": "oldnick", "body": ["newnick"] }
]
}
+6 -5
View File
@@ -2,20 +2,21 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/NOTICE.json",
"title": "NOTICE",
"description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.",
"description": "A notice: a message to a channel or a user that a client must not answer automatically. The server relays it as it relays a PRIVMSG. A NOTICE to a channel with mode +H needs no hashcash stamp. RFC 1459 §4.4.2.",
"$ref": "../message.json",
"properties": {
"command": { "const": "NOTICE" },
"from": { "type": "string" },
"to": { "type": "string", "description": "Target: #channel, nick, or * (global)." },
"from": { "type": "string", "description": "The sender's nick. The server sets it." },
"to": { "type": "string", "description": "A channel name, which starts with #, or a nick." },
"body": {
"type": "array",
"items": { "type": "string" },
"description": "Notice text lines."
"description": "The lines of the notice, one string per line.",
"minItems": 1
}
},
"required": ["command", "to", "body"],
"examples": [
{ "command": "NOTICE", "from": "server.example.com", "to": "*", "body": ["Server restarting in 5 minutes"] }
{ "command": "NOTICE", "from": "alice", "to": "#general", "body": ["Back in five minutes"] }
]
}
+8 -5
View File
@@ -2,17 +2,20 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PART.json",
"title": "PART",
"description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.",
"description": "Leave a channel. A client sends it to leave. The server sends it to every member of the channel when a user leaves, the leaving user included. RFC 1459 §4.2.2.",
"$ref": "../message.json",
"properties": {
"command": { "const": "PART" },
"from": { "type": "string", "description": "Nick that left (S2C)." },
"to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" },
"from": { "type": "string", "description": "The nick that left. The server sets it." },
"to": {
"type": "string",
"description": "The channel name. The server adds the # if a client leaves it out.",
"pattern": "^#?[a-zA-Z0-9_-]{1,63}$"
},
"body": {
"type": "array",
"items": { "type": "string" },
"description": "Optional part reason.",
"maxItems": 1
"description": "The reason for leaving. A client may send one as the first string. The server always sends one string, which is empty when no reason was given."
}
},
"required": ["command", "to"],
+3 -9
View File
@@ -2,19 +2,13 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PING.json",
"title": "PING",
"description": "Keepalive. C2S or S2S. Server responds with PONG. RFC 1459 §4.6.2.",
"description": "A check that the server still answers. A client sends it. The server ignores every field but 'command'. Its answer is a PONG in the HTTP response. RFC 1459 §4.6.2.",
"$ref": "../message.json",
"properties": {
"command": { "const": "PING" },
"body": {
"type": "array",
"items": { "type": "string" },
"description": "Opaque token to be echoed in PONG (single-element array).",
"maxItems": 1
}
"command": { "const": "PING" }
},
"required": ["command"],
"examples": [
{ "command": "PING", "body": ["1707580000"] }
{ "command": "PING" }
]
}
+4 -10
View File
@@ -2,20 +2,14 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PONG.json",
"title": "PONG",
"description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.",
"description": "The answer to a PING. The server sends it as the HTTP response to the PING, with only 'command' and 'from'. A client's PONG gets ERR_UNKNOWNCOMMAND (421). RFC 1459 §4.6.3.",
"$ref": "../message.json",
"properties": {
"command": { "const": "PONG" },
"from": { "type": "string", "description": "Responding server name." },
"body": {
"type": "array",
"items": { "type": "string" },
"description": "Echoed token from PING (single-element array).",
"maxItems": 1
}
"from": { "type": "string", "description": "The server name." }
},
"required": ["command"],
"required": ["command", "from"],
"examples": [
{ "command": "PONG", "from": "server.example.com", "body": ["1707580000"] }
{ "command": "PONG", "from": "neoirc" }
]
}
+5 -5
View File
@@ -2,16 +2,16 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PRIVMSG.json",
"title": "PRIVMSG",
"description": "Send a message to a channel or user. C2S: client sends to server. S2C: server relays to recipients. RFC 1459 §4.4.1.",
"description": "A message to a channel or a user. A client sends it. The server relays it to every member of the channel, or to the user, and to the sender's own clients. RFC 1459 §4.4.1.",
"$ref": "../message.json",
"properties": {
"command": { "const": "PRIVMSG" },
"from": { "type": "string", "description": "Sender nick (set by server on relay)." },
"to": { "type": "string", "description": "Target: #channel or nick.", "examples": ["#general", "alice"] },
"from": { "type": "string", "description": "The sender's nick. The server sets it." },
"to": { "type": "string", "description": "A channel name, which starts with #, or a nick.", "examples": ["#general", "alice"] },
"body": {
"type": "array",
"items": { "type": "string" },
"description": "Message lines. One string per line.",
"description": "The lines of the message, one string per line.",
"minItems": 1
}
},
@@ -19,6 +19,6 @@
"examples": [
{ "command": "PRIVMSG", "from": "bob", "to": "#general", "body": ["hello world"] },
{ "command": "PRIVMSG", "from": "bob", "to": "#general", "body": ["line one", "line two"] },
{ "command": "PRIVMSG", "from": "bob", "to": "alice", "body": ["hey"], "meta": { "sig": "base64...", "alg": "ed25519" } }
{ "command": "PRIVMSG", "from": "bob", "to": "alice", "body": ["hey"] }
]
}
-37
View File
@@ -1,37 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PUBKEY.json",
"title": "PUBKEY",
"description": "Announce or relay a user's public signing key. C2S: client announces key to channel or server. S2C: server relays to channel members. Protocol extension (not in RFC 1459). Body is a structured object (not an array) containing the key material.",
"$ref": "../message.json",
"properties": {
"command": { "const": "PUBKEY" },
"from": { "type": "string", "description": "Nick announcing the key (set by server on relay)." },
"to": {
"type": "string",
"description": "Target: #channel to announce to channel members, or omit for server-wide announcement."
},
"body": {
"type": "object",
"description": "Key material.",
"properties": {
"alg": {
"type": "string",
"description": "Key algorithm.",
"enum": ["ed25519"]
},
"key": {
"type": "string",
"description": "Base64-encoded public key."
}
},
"required": ["alg", "key"],
"additionalProperties": false
}
},
"required": ["command", "body"],
"examples": [
{ "command": "PUBKEY", "from": "alice", "body": { "alg": "ed25519", "key": "base64-encoded-pubkey" } },
{ "command": "PUBKEY", "from": "alice", "to": "#general", "body": { "alg": "ed25519", "key": "base64-encoded-pubkey" } }
]
}
+6 -6
View File
@@ -2,20 +2,20 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/QUIT.json",
"title": "QUIT",
"description": "User disconnected. S2C only. RFC 1459 §4.1.6.",
"description": "A user left the server. A client sends it to end its session. The server sends it to the users who shared a channel with the user who left. RFC 1459 §4.1.6.",
"$ref": "../message.json",
"properties": {
"command": { "const": "QUIT" },
"from": { "type": "string", "description": "Nick that quit." },
"from": { "type": "string", "description": "The nick that quit. The server sets it." },
"body": {
"type": "array",
"items": { "type": "string" },
"description": "Optional quit reason.",
"maxItems": 1
"description": "The reason for leaving. A client may send one as the first string, and the server uses 'Client quit' when it does not. The server always sends one string."
}
},
"required": ["command", "from"],
"required": ["command"],
"examples": [
{ "command": "QUIT", "from": "alice", "body": ["Connection reset"] }
{ "command": "QUIT", "body": ["Gone for the day"] },
{ "command": "QUIT", "from": "alice", "body": ["Connection closed"] }
]
}
+10 -6
View File
@@ -2,20 +2,24 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/TOPIC.json",
"title": "TOPIC",
"description": "Get or set channel topic. C2S: set topic (body present) or query (body absent). S2C: topic change notification. RFC 1459 §4.2.4.",
"description": "Set a channel's topic. A client sends it to set the topic. The server sends it to every member of the channel when the topic changes. RFC 1459 §4.2.4.",
"$ref": "../message.json",
"properties": {
"command": { "const": "TOPIC" },
"from": { "type": "string", "description": "Nick that changed the topic (S2C)." },
"to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" },
"from": { "type": "string", "description": "The nick that set the topic. The server sets it." },
"to": {
"type": "string",
"description": "The channel name. The server adds the # if a client leaves it out.",
"pattern": "^#?[a-zA-Z0-9_-]{1,63}$"
},
"body": {
"type": "array",
"items": { "type": "string" },
"description": "New topic text (single-element array). Empty array clears the topic.",
"maxItems": 1
"description": "The new topic. The server joins a client's strings with spaces, and sends the topic as one string. [\"\"] clears the topic.",
"minItems": 1
}
},
"required": ["command", "to"],
"required": ["command", "to", "body"],
"examples": [
{ "command": "TOPIC", "from": "alice", "to": "#general", "body": ["Welcome to the channel"] }
]
+20 -32
View File
@@ -2,67 +2,55 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/message.json",
"title": "IRC Message Envelope",
"description": "Base envelope for all messages. Mirrors IRC wire format (RFC 1459/2812) encoded as JSON over HTTP. The 'command' field carries either an IRC command name (PRIVMSG, JOIN, etc.) or a three-digit numeric reply code (001, 353, 433, etc.).",
"description": "Every message in the HTTP API is one JSON object with these fields. Only 'command' is always present.",
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid",
"description": "Server-assigned message UUID. Present on all server-originated messages."
"description": "The message's UUID, which the server assigns. Present on every message the server queues for a client."
},
"command": {
"type": "string",
"description": "IRC command name (PRIVMSG, JOIN, NICK, etc.) or three-digit numeric reply code (001, 353, 433, etc.).",
"examples": ["PRIVMSG", "JOIN", "001", "353", "433"]
"description": "An IRC command name, such as PRIVMSG, or the name of a numeric reply, such as RPL_WELCOME. The server reads a client's command in any case and sends it in upper case.",
"examples": ["PRIVMSG", "JOIN", "RPL_WELCOME", "ERR_NICKNAMEINUSE"]
},
"code": {
"type": "integer",
"minimum": 1,
"maximum": 999,
"description": "The number of a numeric reply, such as 1 for RPL_WELCOME. Absent on other messages."
},
"from": {
"type": "string",
"description": "Source — nick for user messages, server name for server messages. Equivalent to IRC prefix."
"description": "The sender's nick, or the server name on a numeric reply or a PONG. The server sets it and ignores it from a client."
},
"to": {
"type": "string",
"description": "Target — channel (#name) or nick. Equivalent to first IRC parameter for most commands."
"description": "The target: a channel name, such as #general, or a nick. A WALLOPS has * here."
},
"params": {
"type": "array",
"items": { "type": "string" },
"description": "Additional parameters (used primarily by numeric replies). Equivalent to IRC middle parameters."
"description": "The message's other parameters, in order. KICK events and many numeric replies have it. The server ignores it from a client."
},
"body": {
"oneOf": [
{
"type": "array",
"items": { "type": "string" },
"description": "Array of strings (one per line for text messages)."
},
{
"type": "object",
"description": "Structured data (e.g. PUBKEY key material).",
"additionalProperties": true
}
],
"description": "Message body. MUST be an array or object, never a raw string. Arrays represent lines of text; objects carry structured data. This enables deterministic canonicalization (RFC 8785 JCS) for signing."
"type": "array",
"items": { "type": "string" },
"description": "An array of strings, never a bare string. In a PRIVMSG or NOTICE, each string is one line of text."
},
"ts": {
"type": "string",
"format": "date-time",
"description": "Server-assigned timestamp (ISO 8601). Not present in original IRC; added for HTTP transport."
"description": "The time the server stored the message, as an RFC 3339 time in UTC."
},
"meta": {
"type": "object",
"description": "Extensible metadata. Used for signatures (meta.sig, meta.alg), hashes (meta.hash), and client extensions.",
"description": "An object of extra data. Every message the server queues for a client has meta, which is {} when there is none. The server keeps a client's meta only on a PRIVMSG or NOTICE, and relays it unchanged.",
"properties": {
"sig": {
"hashcash": {
"type": "string",
"description": "Base64-encoded cryptographic signature over the canonical message form."
},
"alg": {
"type": "string",
"description": "Signature algorithm (e.g. 'ed25519')."
},
"hash": {
"type": "string",
"description": "Hash of the canonical message form (e.g. 'sha256:base64...')."
"description": "A hashcash stamp. A PRIVMSG to a channel with mode +H needs one."
}
},
"additionalProperties": true
+18 -5
View File
@@ -2,32 +2,45 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/001.json",
"title": "001 RPL_WELCOME",
"description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.",
"description": "Welcomes the user. Sent after a session is created, after a login, and in answer to MOTD. RFC 2812 §5.1.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "001"
"const": "RPL_WELCOME"
},
"code": {
"const": 1
},
"from": {
"type": "string",
"description": "The server name."
},
"to": {
"type": "string",
"description": "Target nick."
"description": "The user's nick."
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "Welcome text lines."
"description": "One string: 'Welcome to the network, ' and the user's nick.",
"minItems": 1,
"maxItems": 1
}
},
"required": [
"command",
"code",
"from",
"to",
"body"
],
"examples": [
{
"command": "001",
"command": "RPL_WELCOME",
"code": 1,
"from": "neoirc",
"to": "alice",
"body": [
"Welcome to the network, alice"
+20 -6
View File
@@ -2,34 +2,48 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/002.json",
"title": "002 RPL_YOURHOST",
"description": "Server host info sent after session creation. RFC 2812 \u00a75.1.",
"description": "The server's name and version. Sent right after RPL_WELCOME. RFC 2812 §5.1.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "002"
"const": "RPL_YOURHOST"
},
"code": {
"const": 2
},
"from": {
"type": "string",
"description": "The server name."
},
"to": {
"type": "string"
"type": "string",
"description": "The user's nick."
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "Host info lines."
"description": "One string: 'Your host is ', the server name, ', running version ' and the version.",
"minItems": 1,
"maxItems": 1
}
},
"required": [
"command",
"code",
"from",
"to",
"body"
],
"examples": [
{
"command": "002",
"command": "RPL_YOURHOST",
"code": 2,
"from": "neoirc",
"to": "alice",
"body": [
"Your host is neoirc.example.com, running version 0.1.0"
"Your host is neoirc, running version dev"
]
}
]
+19 -5
View File
@@ -2,31 +2,45 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/003.json",
"title": "003 RPL_CREATED",
"description": "Server creation date. RFC 2812 \u00a75.1.",
"description": "The date the server last started. Sent right after RPL_YOURHOST. RFC 2812 §5.1.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "003"
"const": "RPL_CREATED"
},
"code": {
"const": 3
},
"from": {
"type": "string",
"description": "The server name."
},
"to": {
"type": "string"
"type": "string",
"description": "The user's nick."
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "Text lines."
"description": "One string: 'This server was created ' and the start date as YYYY-MM-DD.",
"minItems": 1,
"maxItems": 1
}
},
"required": [
"command",
"code",
"from",
"to",
"body"
],
"examples": [
{
"command": "003",
"command": "RPL_CREATED",
"code": 3,
"from": "neoirc",
"to": "alice",
"body": [
"This server was created 2026-02-01"
+37 -10
View File
@@ -2,37 +2,64 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/004.json",
"title": "004 RPL_MYINFO",
"description": "Server info (name, version, available modes). RFC 2812 \u00a75.1.",
"description": "The server's name, its version, and its lists of user and channel modes. Sent right after RPL_CREATED. RFC 2812 §5.1.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "004"
"const": "RPL_MYINFO"
},
"code": {
"const": 4
},
"from": {
"type": "string",
"description": "The server name."
},
"to": {
"type": "string"
"type": "string",
"description": "The user's nick."
},
"params": {
"type": "array",
"items": {
"type": "string"
},
"description": "[server_name, version, user_modes, channel_modes]."
"description": "The server name, the version, the user modes 'ow' and the channel modes 'Hbiklmnostv'.",
"minItems": 4,
"maxItems": 4
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "One empty string.",
"minItems": 1,
"maxItems": 1
}
},
"required": [
"command",
"code",
"from",
"to",
"params"
"params",
"body"
],
"examples": [
{
"command": "004",
"command": "RPL_MYINFO",
"code": 4,
"from": "neoirc",
"to": "alice",
"params": [
"neoirc.example.com",
"0.1.0",
"o",
"imnst+ov"
"neoirc",
"dev",
"ow",
"Hbiklmnostv"
],
"body": [
""
]
}
]
+24 -7
View File
@@ -2,38 +2,55 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/322.json",
"title": "322 RPL_LIST",
"description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.",
"description": "One channel in the answer to LIST. The answer has one per channel, except a secret channel, one with mode +s, that the user is not on. RFC 1459 §6.2.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "322"
"const": "RPL_LIST"
},
"code": {
"const": 322
},
"from": {
"type": "string",
"description": "The server name."
},
"to": {
"type": "string"
"type": "string",
"description": "The user's nick."
},
"params": {
"type": "array",
"items": {
"type": "string"
},
"description": "[channel, visible_count]."
"description": "The channel name and its number of members.",
"minItems": 2,
"maxItems": 2
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "Channel topic."
"description": "One string: the channel's topic, empty when it has none.",
"minItems": 1,
"maxItems": 1
}
},
"required": [
"command",
"code",
"from",
"to",
"params"
"params",
"body"
],
"examples": [
{
"command": "322",
"command": "RPL_LIST",
"code": 322,
"from": "neoirc",
"to": "alice",
"params": [
"#general",
+28 -5
View File
@@ -2,26 +2,49 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/323.json",
"title": "323 RPL_LISTEND",
"description": "End of channel list. RFC 1459 \u00a76.2.",
"description": "The end of the answer to LIST. RFC 1459 §6.2.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "323"
"const": "RPL_LISTEND"
},
"code": {
"const": 323
},
"from": {
"type": "string",
"description": "The server name."
},
"to": {
"type": "string"
"type": "string",
"description": "The user's nick."
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "End of /LIST",
"description": "One string: 'End of /LIST'.",
"minItems": 1,
"maxItems": 1
}
},
"required": [
"command",
"to"
"code",
"from",
"to",
"body"
],
"examples": [
{
"command": "RPL_LISTEND",
"code": 323,
"from": "neoirc",
"to": "alice",
"body": [
"End of /LIST"
]
}
]
}
+22 -6
View File
@@ -2,39 +2,55 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/332.json",
"title": "332 RPL_TOPIC",
"description": "Channel topic (sent on JOIN or TOPIC query). RFC 1459 \u00a76.2.",
"description": "A channel's topic. Sent to a client after it sets the topic, and after it joins a channel that has one. Also sent for each of a session's channels that has one after a login, and for GET /api/v1/state?initChannelState=1. RFC 1459 §6.2.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "332"
"const": "RPL_TOPIC"
},
"code": {
"const": 332
},
"from": {
"type": "string",
"description": "The server name."
},
"to": {
"type": "string"
"type": "string",
"description": "The user's nick."
},
"params": {
"type": "array",
"items": {
"type": "string"
},
"description": "[channel]."
"description": "The channel name.",
"minItems": 1,
"maxItems": 1
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "Topic text."
"description": "One string: the topic.",
"minItems": 1,
"maxItems": 1
}
},
"required": [
"command",
"code",
"from",
"to",
"params",
"body"
],
"examples": [
{
"command": "332",
"command": "RPL_TOPIC",
"code": 332,
"from": "neoirc",
"to": "alice",
"params": [
"#general"
+23 -7
View File
@@ -2,46 +2,62 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/353.json",
"title": "353 RPL_NAMREPLY",
"description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.",
"description": "A channel's members. Sent to a client after it joins a channel, and in answer to NAMES. Also sent for each of a session's channels after a login, and for GET /api/v1/state?initChannelState=1. RFC 1459 §6.2.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "353"
"const": "RPL_NAMREPLY"
},
"code": {
"const": 353
},
"from": {
"type": "string",
"description": "The server name."
},
"to": {
"type": "string"
"type": "string",
"description": "The user's nick."
},
"params": {
"type": "array",
"items": {
"type": "string"
},
"description": "[channel_type, channel]. channel_type: = (public), * (private), @ (secret)."
"description": "'=' and the channel name. The server always sends '='.",
"minItems": 2,
"maxItems": 2
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "Space-separated list of nicks. Prefixed with @ for ops, + for voiced."
"description": "One string: the members as nick!user@host, separated by spaces. '@' comes before a channel operator and '+' before a voiced member. The host is '*' when the server does not know it.",
"minItems": 1,
"maxItems": 1
}
},
"required": [
"command",
"code",
"from",
"to",
"params",
"body"
],
"examples": [
{
"command": "353",
"command": "RPL_NAMREPLY",
"code": 353,
"from": "neoirc",
"to": "alice",
"params": [
"=",
"#general"
],
"body": [
"@op1 alice bob +voiced1"
"@op1!op1@host1 alice!alice@host2 +bob!bob@host3"
]
}
]
+34 -6
View File
@@ -2,34 +2,62 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/366.json",
"title": "366 RPL_ENDOFNAMES",
"description": "End of NAMES list. RFC 1459 \u00a76.2.",
"description": "The end of a channel's member list. It comes after the RPL_NAMREPLY for the channel. RFC 1459 §6.2.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "366"
"const": "RPL_ENDOFNAMES"
},
"code": {
"const": 366
},
"from": {
"type": "string",
"description": "The server name."
},
"to": {
"type": "string"
"type": "string",
"description": "The user's nick."
},
"params": {
"type": "array",
"items": {
"type": "string"
},
"description": "[channel]."
"description": "The channel name.",
"minItems": 1,
"maxItems": 1
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "End of /NAMES list",
"description": "One string: 'End of /NAMES list'.",
"minItems": 1,
"maxItems": 1
}
},
"required": [
"command",
"code",
"from",
"to",
"params"
"params",
"body"
],
"examples": [
{
"command": "RPL_ENDOFNAMES",
"code": 366,
"from": "neoirc",
"to": "alice",
"params": [
"#general"
],
"body": [
"End of /NAMES list"
]
}
]
}
+19 -5
View File
@@ -2,31 +2,45 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/372.json",
"title": "372 RPL_MOTD",
"description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.",
"description": "One line of the message of the day. The server sends one per line, after RPL_MOTDSTART. RFC 2812 §5.1.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "372"
"const": "RPL_MOTD"
},
"code": {
"const": 372
},
"from": {
"type": "string",
"description": "The server name."
},
"to": {
"type": "string"
"type": "string",
"description": "The user's nick."
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "MOTD line text (prefixed with '- ')."
"description": "One string: '- ' and the line.",
"minItems": 1,
"maxItems": 1
}
},
"required": [
"command",
"code",
"from",
"to",
"body"
],
"examples": [
{
"command": "372",
"command": "RPL_MOTD",
"code": 372,
"from": "neoirc",
"to": "alice",
"body": [
"- Welcome to our server!"
+29 -5
View File
@@ -2,25 +2,49 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/375.json",
"title": "375 RPL_MOTDSTART",
"description": "Start of MOTD. RFC 2812 \u00a75.1.",
"description": "The start of the message of the day. Sent after RPL_LUSERME (255) when the server has a message of the day. RFC 2812 §5.1.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "375"
"const": "RPL_MOTDSTART"
},
"code": {
"const": 375
},
"from": {
"type": "string",
"description": "The server name."
},
"to": {
"type": "string"
"type": "string",
"description": "The user's nick."
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "Text lines."
"description": "One string: '- ', the server name and ' Message of the Day -'.",
"minItems": 1,
"maxItems": 1
}
},
"required": [
"command",
"to"
"code",
"from",
"to",
"body"
],
"examples": [
{
"command": "RPL_MOTDSTART",
"code": 375,
"from": "neoirc",
"to": "alice",
"body": [
"- neoirc Message of the Day -"
]
}
]
}
+28 -5
View File
@@ -2,26 +2,49 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/376.json",
"title": "376 RPL_ENDOFMOTD",
"description": "End of MOTD. RFC 2812 \u00a75.1.",
"description": "The end of the message of the day. RFC 2812 §5.1.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "376"
"const": "RPL_ENDOFMOTD"
},
"code": {
"const": 376
},
"from": {
"type": "string",
"description": "The server name."
},
"to": {
"type": "string"
"type": "string",
"description": "The user's nick."
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "End of /MOTD command",
"description": "One string: 'End of /MOTD command.'.",
"minItems": 1,
"maxItems": 1
}
},
"required": [
"command",
"to"
"code",
"from",
"to",
"body"
],
"examples": [
{
"command": "RPL_ENDOFMOTD",
"code": 376,
"from": "neoirc",
"to": "alice",
"body": [
"End of /MOTD command."
]
}
]
}
+24 -8
View File
@@ -2,45 +2,61 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/401.json",
"title": "401 ERR_NOSUCHNICK",
"description": "No such nick/channel. RFC 1459 \u00a76.1.",
"description": "No user has that nick. RFC 1459 §6.1.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "401"
"const": "ERR_NOSUCHNICK"
},
"code": {
"const": 401
},
"from": {
"type": "string",
"description": "The server name."
},
"to": {
"type": "string"
"type": "string",
"description": "The user's nick."
},
"params": {
"type": "array",
"items": {
"type": "string"
},
"description": "[target_nick]."
"description": "The nick that was not found.",
"minItems": 1,
"maxItems": 1
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "No such nick/channel",
"description": "One string: 'No such nick' in answer to a PRIVMSG or NOTICE, and 'No such nick/channel' in answer to anything else.",
"minItems": 1,
"maxItems": 1
}
},
"required": [
"command",
"code",
"from",
"to",
"params"
"params",
"body"
],
"examples": [
{
"command": "401",
"command": "ERR_NOSUCHNICK",
"code": 401,
"from": "neoirc",
"to": "alice",
"params": [
"bob"
],
"body": [
"No such nick/channel"
"No such nick"
]
}
]
+23 -7
View File
@@ -2,39 +2,55 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/403.json",
"title": "403 ERR_NOSUCHCHANNEL",
"description": "No such channel. RFC 1459 \u00a76.1.",
"description": "No channel has that name. A JOIN with a channel name that the server does not allow gets it too. RFC 1459 §6.1.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "403"
"const": "ERR_NOSUCHCHANNEL"
},
"code": {
"const": 403
},
"from": {
"type": "string",
"description": "The server name."
},
"to": {
"type": "string"
"type": "string",
"description": "The user's nick."
},
"params": {
"type": "array",
"items": {
"type": "string"
},
"description": "[channel_name]."
"description": "The channel name.",
"minItems": 1,
"maxItems": 1
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "No such channel",
"description": "One string: 'No such channel'.",
"minItems": 1,
"maxItems": 1
}
},
"required": [
"command",
"code",
"from",
"to",
"params"
"params",
"body"
],
"examples": [
{
"command": "403",
"command": "ERR_NOSUCHCHANNEL",
"code": 403,
"from": "neoirc",
"to": "alice",
"params": [
"#nonexistent"
+25 -9
View File
@@ -2,42 +2,58 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/433.json",
"title": "433 ERR_NICKNAMEINUSE",
"description": "Nickname is already in use. RFC 1459 \u00a76.1.",
"description": "Another user has the nick. Sent in answer to a NICK. RFC 1459 §6.1.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "433"
"const": "ERR_NICKNAMEINUSE"
},
"code": {
"const": 433
},
"from": {
"type": "string",
"description": "The server name."
},
"to": {
"type": "string"
"type": "string",
"description": "The user's nick, which has not changed."
},
"params": {
"type": "array",
"items": {
"type": "string"
},
"description": "[requested_nick]."
"description": "The nick that was asked for.",
"minItems": 1,
"maxItems": 1
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "Nickname is already in use",
"description": "One string: 'Nickname is already in use'.",
"minItems": 1,
"maxItems": 1
}
},
"required": [
"command",
"code",
"from",
"to",
"params"
"params",
"body"
],
"examples": [
{
"command": "433",
"to": "*",
"command": "ERR_NICKNAMEINUSE",
"code": 433,
"from": "neoirc",
"to": "alice",
"params": [
"alice"
"bob"
],
"body": [
"Nickname is already in use"
+34 -6
View File
@@ -2,34 +2,62 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/442.json",
"title": "442 ERR_NOTONCHANNEL",
"description": "You're not on that channel. RFC 1459 \u00a76.1.",
"description": "The user is not on that channel. RFC 1459 §6.1.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "442"
"const": "ERR_NOTONCHANNEL"
},
"code": {
"const": 442
},
"from": {
"type": "string",
"description": "The server name."
},
"to": {
"type": "string"
"type": "string",
"description": "The user's nick."
},
"params": {
"type": "array",
"items": {
"type": "string"
},
"description": "[channel]."
"description": "The channel name.",
"minItems": 1,
"maxItems": 1
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "You're not on that channel",
"description": "One string: 'You're not on that channel'.",
"minItems": 1,
"maxItems": 1
}
},
"required": [
"command",
"code",
"from",
"to",
"params"
"params",
"body"
],
"examples": [
{
"command": "ERR_NOTONCHANNEL",
"code": 442,
"from": "neoirc",
"to": "alice",
"params": [
"#general"
],
"body": [
"You're not on that channel"
]
}
]
}
+34 -6
View File
@@ -2,34 +2,62 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/482.json",
"title": "482 ERR_CHANOPRIVSNEEDED",
"description": "You're not channel operator. RFC 1459 \u00a76.1.",
"description": "The user is not a channel operator, and the command needs one. RFC 1459 §6.1.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "482"
"const": "ERR_CHANOPRIVSNEEDED"
},
"code": {
"const": 482
},
"from": {
"type": "string",
"description": "The server name."
},
"to": {
"type": "string"
"type": "string",
"description": "The user's nick."
},
"params": {
"type": "array",
"items": {
"type": "string"
},
"description": "[channel]."
"description": "The channel name.",
"minItems": 1,
"maxItems": 1
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "You're not channel operator",
"description": "One string: 'You're not channel operator'.",
"minItems": 1,
"maxItems": 1
}
},
"required": [
"command",
"code",
"from",
"to",
"params"
"params",
"body"
],
"examples": [
{
"command": "ERR_CHANOPRIVSNEEDED",
"code": 482,
"from": "neoirc",
"to": "alice",
"params": [
"#general"
],
"body": [
"You're not channel operator"
]
}
]
}
+91 -54
View File
@@ -7,6 +7,11 @@ 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.
@@ -270,9 +275,9 @@ function UserList({ members, onNickClick }) {
function App() {
const [loggedIn, setLoggedIn] = useState(false);
const [nick, setNick] = useState("");
const [tabs, setTabs] = useState([{ type: "server", name: "Server" }]);
const [tabs, setTabs] = useState([{ type: "server", name: SERVER_TAB }]);
const [activeTab, setActiveTab] = useState(0);
const [messages, setMessages] = useState({ Server: [] });
const [messages, setMessages] = useState({ [SERVER_TAB]: [] });
const [members, setMembers] = useState({});
const [topics, setTopics] = useState({});
const [unread, setUnread] = useState({});
@@ -355,12 +360,24 @@ function App() {
.catch(() => {});
}, []);
// removeChannelTab closes a channel's tab and shows the Server tab.
// 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) =>
prev.filter((t) => !(t.type === "channel" && t.name === name)),
);
setActiveTab(0);
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(
@@ -453,6 +470,15 @@ function App() {
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);
@@ -547,7 +573,7 @@ function App() {
const chName = msg.params[0];
const count = msg.params[1];
const chTopic = body || "";
addMessage("Server", {
addMessage(SERVER_TAB, {
...base,
text: `${chName} (${count} users): ${chTopic.trim()}`,
system: true,
@@ -557,7 +583,7 @@ function App() {
break;
}
case 323:
addMessage("Server", {
addMessage(SERVER_TAB, {
...base,
text: body || "End of channel list",
system: true,
@@ -570,7 +596,7 @@ function App() {
const whoCh = msg.params[0];
const whoNick = msg.params[4];
const whoFlags = msg.params.length > 5 ? msg.params[5] : "";
addMessage("Server", {
addMessage(SERVER_TAB, {
...base,
text: `${whoCh} ${whoNick} ${whoFlags}`,
system: true,
@@ -580,7 +606,7 @@ function App() {
break;
}
case 315:
addMessage("Server", {
addMessage(SERVER_TAB, {
...base,
text: body || "End of /WHO list",
system: true,
@@ -591,7 +617,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", {
addMessage(SERVER_TAB, {
...base,
text: `${wiNick} is ${wiUser}@${wiHost} (${body})`,
system: true,
@@ -605,7 +631,7 @@ function App() {
if (Array.isArray(msg.params) && msg.params.length >= 2) {
const wiNick = msg.params[0];
const wiServer = msg.params[1];
addMessage("Server", {
addMessage(SERVER_TAB, {
...base,
text: `${wiNick} on ${wiServer}`,
system: true,
@@ -617,7 +643,7 @@ function App() {
case 313: {
// RPL_WHOISOPERATOR — nick; body "is an IRC operator".
if (Array.isArray(msg.params) && msg.params.length >= 1) {
addMessage("Server", {
addMessage(SERVER_TAB, {
...base,
text: `${msg.params[0]} ${body}`,
system: true,
@@ -631,7 +657,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", {
addMessage(SERVER_TAB, {
...base,
text: `${wiNick} has been idle ${idle}s, signed on ${signedOn}`,
system: true,
@@ -644,7 +670,7 @@ function App() {
// RPL_WHOISCHANNELS — nick, channels.
if (Array.isArray(msg.params) && msg.params.length >= 1) {
const wiNick = msg.params[0];
addMessage("Server", {
addMessage(SERVER_TAB, {
...base,
text: `${wiNick} is on: ${body}`,
system: true,
@@ -654,7 +680,7 @@ function App() {
break;
}
case 318:
addMessage("Server", {
addMessage(SERVER_TAB, {
...base,
text: body || "End of /WHOIS list",
system: true,
@@ -664,7 +690,7 @@ function App() {
case 324: {
// RPL_CHANNELMODEIS — channel, mode string.
if (Array.isArray(msg.params) && msg.params.length >= 2) {
addMessage("Server", {
addMessage(SERVER_TAB, {
...base,
text: `${msg.params[0]} modes: ${msg.params[1]}`,
system: true,
@@ -676,12 +702,12 @@ function App() {
case 375:
case 372:
case 376:
addMessage("Server", { ...base, text: body, system: true });
addMessage(SERVER_TAB, { ...base, text: body, system: true });
break;
default:
if (body) {
addMessage("Server", { ...base, text: body, system: true });
addMessage(SERVER_TAB, { ...base, text: body, system: true });
}
}
},
@@ -807,7 +833,7 @@ function App() {
);
setNick(userNick);
setLoggedIn(true);
addSystemMessage("Server", `Connected as ${userNick}`);
addSystemMessage(SERVER_TAB, `Connected as ${userNick}`);
if (isResumed) {
// Request MOTD on resumed sessions (new sessions
@@ -831,10 +857,11 @@ function App() {
// Fresh session — join any previously saved channels.
for (const ch of saved) {
try {
await api("/messages", {
const res = 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;
@@ -854,10 +881,13 @@ function App() {
name = name.trim();
if (!name.startsWith("#")) name = "#" + name;
try {
await api("/messages", {
const res = 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
@@ -887,7 +917,7 @@ function App() {
}
} catch (err) {
addSystemMessage(
"Server",
SERVER_TAB,
`Failed to join ${name}: ${err.data?.error || "error"}`,
);
}
@@ -918,16 +948,23 @@ 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) => {
if (prev.find((t) => t.type === "dm" && t.name === targetNick))
const idx = prev.findIndex(
(t) => t.type === "dm" && t.name === targetNick,
);
if (idx >= 0) {
setActiveTab(idx);
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) => {
@@ -938,7 +975,7 @@ function App() {
switch (cmd) {
case "/join": {
if (parts[1]) joinChannel(parts[1]);
else addSystemMessage("Server", "Usage: /join #channel");
else addSystemMessage(SERVER_TAB, "Usage: /join #channel");
break;
}
@@ -947,7 +984,7 @@ function App() {
const reason = parts.slice(1).join(" ") || undefined;
partChannel(tab.name, reason);
} else {
addSystemMessage("Server", "You are not in a channel");
addSystemMessage(SERVER_TAB, "You are not in a channel");
}
break;
@@ -968,19 +1005,19 @@ function App() {
openDM(target);
} catch (err) {
addSystemMessage(
"Server",
SERVER_TAB,
`Message failed: ${err.data?.error || "error"}`,
);
}
} else {
addSystemMessage("Server", "Usage: /msg <nick> <message>");
addSystemMessage(SERVER_TAB, "Usage: /msg <nick> <message>");
}
break;
}
case "/me": {
if (tab.type === "server") {
addSystemMessage("Server", "Cannot use /me in server window");
addSystemMessage(SERVER_TAB, "Cannot use /me in server window");
break;
}
@@ -1002,7 +1039,7 @@ function App() {
);
}
} else {
addSystemMessage("Server", "Usage: /me <action>");
addSystemMessage(SERVER_TAB, "Usage: /me <action>");
}
break;
@@ -1016,19 +1053,19 @@ function App() {
});
} catch (err) {
addSystemMessage(
"Server",
SERVER_TAB,
`Nick change failed: ${err.data?.error || "error"}`,
);
}
} else {
addSystemMessage("Server", "Usage: /nick <newnick>");
addSystemMessage(SERVER_TAB, "Usage: /nick <newnick>");
}
break;
}
case "/topic": {
if (tab.type !== "channel") {
addSystemMessage("Server", "You are not in a channel");
addSystemMessage(SERVER_TAB, "You are not in a channel");
break;
}
@@ -1045,13 +1082,13 @@ function App() {
});
} catch (err) {
addSystemMessage(
"Server",
SERVER_TAB,
`Topic change failed: ${err.data?.error || "error"}`,
);
}
} else {
addSystemMessage(
"Server",
SERVER_TAB,
`Current topic for ${tab.name}: ${topics[tab.name] || "(none)"}`,
);
}
@@ -1060,7 +1097,7 @@ function App() {
}
case "/mode": {
if (tab.type !== "channel") {
addSystemMessage("Server", "You are not in a channel");
addSystemMessage(SERVER_TAB, "You are not in a channel");
break;
}
@@ -1078,7 +1115,7 @@ function App() {
});
} catch (err) {
addSystemMessage(
"Server",
SERVER_TAB,
`MODE failed: ${err.data?.error || "error"}`,
);
}
@@ -1109,7 +1146,7 @@ function App() {
});
} catch (err) {
addSystemMessage(
"Server",
SERVER_TAB,
`Failed to request MOTD: ${err.data?.error || "error"}`,
);
}
@@ -1133,13 +1170,13 @@ function App() {
});
} catch (err) {
addSystemMessage(
"Server",
SERVER_TAB,
`Message failed: ${err.data?.error || "error"}`,
);
}
}
} else {
addSystemMessage("Server", "Usage: /query <nick> [message]");
addSystemMessage(SERVER_TAB, "Usage: /query <nick> [message]");
}
break;
@@ -1152,7 +1189,7 @@ function App() {
});
} catch (err) {
addSystemMessage(
"Server",
SERVER_TAB,
`Failed to list channels: ${err.data?.error || "error"}`,
);
}
@@ -1169,12 +1206,12 @@ function App() {
});
} catch (err) {
addSystemMessage(
"Server",
SERVER_TAB,
`WHO failed: ${err.data?.error || "error"}`,
);
}
} else {
addSystemMessage("Server", "Usage: /who #channel");
addSystemMessage(SERVER_TAB, "Usage: /who #channel");
}
break;
@@ -1188,12 +1225,12 @@ function App() {
});
} catch (err) {
addSystemMessage(
"Server",
SERVER_TAB,
`WHOIS failed: ${err.data?.error || "error"}`,
);
}
} else {
addSystemMessage("Server", "Usage: /whois <nick>");
addSystemMessage(SERVER_TAB, "Usage: /whois <nick>");
}
break;
@@ -1224,13 +1261,13 @@ function App() {
" /help — Show this help",
];
for (const line of helpLines) {
addSystemMessage("Server", line);
addSystemMessage(SERVER_TAB, line);
}
break;
}
default:
addSystemMessage("Server", `Unknown command: ${cmd}`);
addSystemMessage(SERVER_TAB, `Unknown command: ${cmd}`);
}
};
@@ -1259,7 +1296,7 @@ function App() {
if (tab.type === "server") {
addSystemMessage(
"Server",
SERVER_TAB,
"Cannot send messages to the server window. Use /join #channel first.",
);
@@ -1330,7 +1367,7 @@ function App() {
key={tab.name}
>
<span class="tab-label">
{tab.type === "dm" ? tab.name : tab.name}
{tab.type === "server" ? "Server" : tab.name}
</span>
{unread[tab.name] > 0 && i !== activeTab && (
<span class="unread-count">({unread[tab.name]})</span>