Author SHA1 Message Date
sneak ce3a989321 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 03:51:33 +00: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
15 changed files with 1244 additions and 155 deletions
+1 -1
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","ikmnostl"]}` |
| `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"]}` |
+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])
}
+633 -4
View File
@@ -4,7 +4,10 @@ import (
"io"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
"github.com/gdamore/tcell/v2"
api "sneak.berlin/go/neoirc/internal/cli/api"
@@ -20,6 +23,24 @@ const (
testText = "hello"
)
// eventLoopTimeout is how long a test waits for a typed line to
// be handled, for the welcome lines, or for the UI to stop, 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()
@@ -219,6 +240,518 @@ func TestJoinResponse(t *testing.T) {
}
}
// 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.
@@ -301,6 +834,49 @@ 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 {
@@ -316,22 +892,75 @@ func newServer(t *testing.T, body string) *httptest.Server {
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", "")
+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, "ikmnostl"},
"",
)
+24
View File
@@ -6797,6 +6797,30 @@ func TestISUPPORT(t *testing.T) {
}
}
// TestMyInfoListsUserModes verifies the 004 numeric lists
// the user modes o and w as its third parameter.
func TestMyInfoListsUserModes(t *testing.T) {
t.Parallel()
tserver := newTestServer(t)
token := tserver.createSession("myinfo")
msgs, _ := tserver.pollMessages(token, 0)
myInfo := findNumericWithParams(msgs, "004")
if myInfo == nil {
t.Fatal("expected 004 RPL_MYINFO")
}
params := getNumericParams(myInfo)
if len(params) != 4 || params[2] != "ow" {
t.Fatalf(
"004 params %v, want four with user modes ow third",
params,
)
}
}
// TestNonOpCannotSetModes verifies non-operators
// cannot set +i, +s, +k, +l, +b.
func TestNonOpCannotSetModes(t *testing.T) {
+1 -1
View File
@@ -605,7 +605,7 @@ func (c *Conn) deliverWelcome() {
)
c.sendNumeric(
irc.RplMyInfo,
c.serverSfx, "neoirc", "", "mnst",
c.serverSfx, "neoirc", service.UserModes, "mnst",
)
c.sendNumeric(
irc.RplIsupport,
+16
View File
@@ -486,6 +486,22 @@ func TestWelcomeContainsNick(t *testing.T) {
}
}
// TestMyInfoListsUserModes checks that RPL_MYINFO lists the
// user modes o and w, with no empty parameter before the
// channel modes.
func TestMyInfoListsUserModes(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
lines := env.dial(t).register("carol")
assertContains(
t, lines,
":test.irc 004 carol test.irc neoirc ow mnst",
"RPL_MYINFO with user modes",
)
}
func TestPingPong(t *testing.T) {
t.Parallel()
+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,
+5 -12
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"net"
"net/http"
"strconv"
"strings"
"testing"
"time"
@@ -55,20 +56,12 @@ func TestStopWaitsForRequestInProgress(t *testing.T) {
t.Fatalf("create session: %v", err)
}
// The test cannot read the port the system gave the app, so
// it serves the same HTTP server on a listener of its own.
var listenConfig net.ListenConfig
listener, err := listenConfig.Listen(
t.Context(), "tcp", "127.0.0.1:0",
)
if err != nil {
t.Fatalf("listen: %v", err)
tcpAddr, ok := srv.Listener().Addr().(*net.TCPAddr)
if !ok {
t.Fatalf("listener address %v is not TCP", srv.Listener().Addr())
}
go srv.ServeUntilShutdown(listener)
addr := listener.Addr().String()
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(tcpAddr.Port))
statuses := make(chan int, 1)
+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
+147 -79
View File
@@ -1,98 +1,166 @@
# 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 server message.
- `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.
Commands, in `commands/`:
| 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 |
- `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`: a user joined a channel.
- `PART.json`: a user left a channel.
- `QUIT.json`: a user left the server.
- `NICK.json`: a user changed nick.
- `TOPIC.json`: a channel topic was set.
- `MODE.json`: a channel mode was changed.
- `KICK.json`: a channel operator removed a user from a channel.
- `PING.json`: a check that the server still answers.
- `PONG.json`: the answer to a `PING`.
- `PUBKEY.json`: an announcement of a user's public signing key. The server has
no such command. It answers a `PUBKEY` with `ERR_UNKNOWNCOMMAND` (`421`).
## Numeric Replies
Numeric replies, in `numerics/`:
Three-digit codes for server responses, per IRC convention.
- `001.json`: `RPL_WELCOME`, which welcomes a new user.
- `002.json`: `RPL_YOURHOST`, the server's name and version.
- `003.json`: `RPL_CREATED`, which says when the server was created.
- `004.json`: `RPL_MYINFO`, the server's 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.
### Success / Informational (0xx–3xx)
## Where the files differ from the HTTP API
| 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 |
Where a schema file and the HTTP API disagree, the HTTP API is right:
### Errors (4xx)
- `message.json` and the files in `numerics/` put the three-digit code in
`command`. They have no `code` field. The HTTP API puts the reply's name in
`command` and its number in `code`.
- `message.json` allows an object as `body`. The HTTP API reads only arrays of
strings. A `PRIVMSG` with an object as `body` gets `ERR_NOTEXTTOSEND` (`412`).
- `message.json` says `to` is a channel or a nick. A `WALLOPS` message has `*`
there.
- `message.json` allows `meta` on every message. The HTTP API keeps a client's
`meta` only on a `PRIVMSG` or `NOTICE`.
- `message.json` describes signature fields in `meta`. The server never checks a
signature.
- `message.json` does not describe `meta.hashcash`. The HTTP API reads it in a
`PRIVMSG` to a channel with mode `+H`.
- `JOIN.json` has no `body`. A client's `JOIN` can carry the channel key in
`body`. When a user joins, the `JOIN` event repeats the channel name in
`body`.
- `JOIN.json` allows a channel name of any length. The server allows at most 63
characters after the `#`. It answers a longer name with `ERR_NOSUCHCHANNEL`
(`403`).
- `KICK.json` puts the kicked nick in `params`. The HTTP API reads a client's
`KICK` from `body`: the nick, then an optional reason.
- `MODE.json` puts the mode change in `params`. The HTTP API uses `body`
instead. A client sends the change and any value as separate strings, such as
`"+o"` and `"bob"`. In a `MODE` event, `body` holds the change as one string,
such as `"+o bob"`, or as two strings, such as `"+k"` and `"secret"`.
- `NOTICE.json` allows `*` as `to`. The server answers a `NOTICE` to `*` with
`ERR_NOSUCHNICK` (`401`).
- `NOTICE.json` has an example notice from the server to `*`. The server sends
no such notice.
- `PING.json` and `PONG.json` describe a token that the `PONG` repeats. The HTTP
API ignores the `body` of a `PING`. Its answer is the HTTP response
`{"command": "PONG", "from": "<server name>"}`.
- `PING.json` and `PONG.json` also describe messages between servers. The server
does not connect to other IRC servers.
- `PUBKEY.json` describes a command the server does not have.
- `QUIT.json` says that `QUIT` comes only from the server. A client can send it
too, to end its session. A client's `QUIT` can carry a reason in `body`.
- `TOPIC.json` says a `TOPIC` without a `body` queries the topic. The HTTP API
answers such a `TOPIC` with `ERR_NEEDMOREPARAMS` (`461`).
- `TOPIC.json` says an empty array clears the topic. The HTTP API answers `[]`
with `ERR_NEEDMOREPARAMS` (`461`). A `body` of `[""]` clears the topic.
- `003.json` describes the server's creation date. The HTTP API sends the date
the server last started.
- `004.json` has example mode lists. The HTTP API sends `ow` as the user modes
and `ikmnostl` as the channel modes.
- `322.json` says `LIST` gets one reply per channel. The server leaves out a
secret channel, one with mode `+s`, unless the user is on it.
- `332.json` says `RPL_TOPIC` answers a `TOPIC` query. The HTTP API has no such
query. It sends `RPL_TOPIC` after a client sets the topic, and after a `JOIN`
to a channel that has one.
- `353.json` says `body` lists nicks. The HTTP API lists `nick!user@host` in
place of each nick, such as `@op1!op1@host1`.
- `353.json` says the first parameter marks a channel as public, private or
secret. The server always sends `=`, the mark for a public channel.
- `376.json` gives the text as `End of /MOTD command`. The HTTP API adds a full
stop.
- `401.json` gives the text as `No such nick/channel`. The server sends
`No such nick` in answer to a `PRIVMSG` or `NOTICE` to an unknown nick.
- `433.json` has an example with `*` as `to`. The HTTP API sends `433` only in
answer to a `NICK`, with the user's nick as `to`.
| 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.
The HTTP API also accepts some messages that the files do not allow, such as a
`JOIN` whose `to` has no `#`.