3 Commits
Author SHA1 Message Date
clawbot 967930f9bc Make Alt and a digit set the send target as /window does (closes #221)
check / check (push) Waiting to run
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) Waiting to run
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) Waiting to run
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
6 changed files with 390 additions and 73 deletions
+4
View File
@@ -627,6 +627,10 @@ func (a *App) cmdWindow(args string) {
nick, buf.Name, "connected", nick, buf.Name, "connected",
) )
} else { } else {
a.mu.Lock()
a.target = ""
a.mu.Unlock()
a.ui.SetStatus(nick, "", "connected") a.ui.SetStatus(nick, "", "connected")
} }
} }
+57
View File
@@ -219,6 +219,63 @@ 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}
app.ui.OnInput(app.handleInput)
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.GetInputCapture()(key)
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)
}
})
}
}
// TestOwnEventFromOtherClient checks that a message, notice, // TestOwnEventFromOtherClient checks that a message, notice,
// join or part that the user's session makes from another // join or part that the user's session makes from another
// client shows as one line in the buffer it belongs to. // client shows as one line in the buffer it belongs to.
+5 -2
View File
@@ -240,10 +240,13 @@ func (ui *UI) initKeyCapture() {
return event return event
} }
// Alt and a digit runs /window with that digit.
// SwitchBuffer alone would leave the target as it was.
r := event.Rune() r := event.Rune()
if r >= '0' && r <= '9' { if r >= '0' && r <= '9' {
idx := int(r - '0') if ui.onInput != nil {
ui.SwitchBuffer(idx) ui.onInput("/window " + string(r))
}
return nil return nil
} }
+103 -7
View File
@@ -3,8 +3,12 @@ package config
import ( import (
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"math"
"os" "os"
"strconv"
"time"
"github.com/spf13/viper" "github.com/spf13/viper"
"go.uber.org/fx" "go.uber.org/fx"
@@ -28,6 +32,8 @@ Type /help for available commands.`
const DefaultDBURL = "file:///var/lib/neoirc/state.db" + const DefaultDBURL = "file:///var/lib/neoirc/state.db" +
"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)" "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
var errOutOfRange = errors.New("setting out of range")
// Params defines the dependencies for creating a Config. // Params defines the dependencies for creating a Config.
type Params struct { type Params struct {
fx.In fx.In
@@ -63,6 +69,8 @@ type Config struct {
} }
// New creates a new Config by reading from files and environment variables. // 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( func New(
_ fx.Lifecycle, params Params, _ fx.Lifecycle, params Params,
) (*Config, error) { ) (*Config, error) {
@@ -107,29 +115,27 @@ func New(
cfg := &Config{ cfg := &Config{
DBURL: viper.GetString("DBURL"), DBURL: viper.GetString("DBURL"),
Debug: viper.GetBool("DEBUG"),
Port: viper.GetInt("PORT"),
SentryDSN: viper.GetString("SENTRY_DSN"), SentryDSN: viper.GetString("SENTRY_DSN"),
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
MetricsUsername: viper.GetString("METRICS_USERNAME"), MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"), MetricsPassword: viper.GetString("METRICS_PASSWORD"),
MessageMaxAge: viper.GetString("MESSAGE_MAX_AGE"), MessageMaxAge: viper.GetString("MESSAGE_MAX_AGE"),
MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"),
QueueMaxAge: viper.GetString("QUEUE_MAX_AGE"), QueueMaxAge: viper.GetString("QUEUE_MAX_AGE"),
MOTD: viper.GetString("MOTD"), MOTD: viper.GetString("MOTD"),
ServerName: viper.GetString("SERVER_NAME"), ServerName: viper.GetString("SERVER_NAME"),
FederationKey: viper.GetString("FEDERATION_KEY"), FederationKey: viper.GetString("FEDERATION_KEY"),
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"), SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
OperName: viper.GetString("NEOIRC_OPER_NAME"), OperName: viper.GetString("NEOIRC_OPER_NAME"),
OperPassword: viper.GetString("NEOIRC_OPER_PASSWORD"), OperPassword: viper.GetString("NEOIRC_OPER_PASSWORD"),
LoginRateLimit: viper.GetFloat64("LOGIN_RATE_LIMIT"),
LoginRateBurst: viper.GetInt("LOGIN_RATE_BURST"),
IRCListenAddr: viper.GetString("IRC_LISTEN_ADDR"), IRCListenAddr: viper.GetString("IRC_LISTEN_ADDR"),
log: log, log: log,
params: &params, params: &params,
} }
err = parseSettings(cfg)
if err != nil {
return nil, err
}
// Viper counts an empty environment variable as unset, but an // Viper counts an empty environment variable as unset, but an
// empty IRC_LISTEN_ADDR must turn the IRC listener off. // empty IRC_LISTEN_ADDR must turn the IRC listener off.
ircListenAddr, set := os.LookupEnv("IRC_LISTEN_ADDR") ircListenAddr, set := os.LookupEnv("IRC_LISTEN_ADDR")
@@ -144,3 +150,93 @@ func New(
return cfg, nil 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 ( import (
"os" "os"
"strings"
"testing" "testing"
"time" "time"
@@ -10,7 +11,8 @@ import (
"sneak.berlin/go/neoirc/internal/logger" "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() t.Helper()
globs := &globals.Globals{ globs := &globals.Globals{
@@ -24,9 +26,15 @@ func newConfig(t *testing.T) *config.Config {
t.Fatal(err) t.Fatal(err)
} }
cfg, err := config.New( return config.New(
nil, config.Params{Globals: globs, Logger: log}, nil, config.Params{Globals: globs, Logger: log},
) )
}
func newConfig(t *testing.T) *config.Config {
t.Helper()
cfg, err := loadConfig(t)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -34,6 +42,145 @@ func newConfig(t *testing.T) *config.Config {
return cfg 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) { func TestIRCListenAddrUnsetKeepsDefault(t *testing.T) {
// t.Setenv puts back whatever value was there after the test. // t.Setenv puts back whatever value was there after the test.
t.Setenv("IRC_LISTEN_ADDR", "") t.Setenv("IRC_LISTEN_ADDR", "")
+72 -62
View File
@@ -1,52 +1,58 @@
# Message schemas # Message schemas
This directory holds JSON Schema files (draft 2020-12) for the messages that the This directory holds JSON Schema files (draft 2020-12) for the messages that the
neoirc HTTP API carries. `message.json` describes the envelope every message neoirc HTTP API carries. `message.json` describes the fields that every message
uses. `commands/` has files for some of the IRC commands. `numerics/` has files can have. `commands/` has files for some commands. `numerics/` has files for
for some of the numeric replies. `README.md` at the repository root documents 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. the protocol.
## The 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 one JSON object. These are the fields it can have: ## 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 - `command`: the IRC command name, such as `PRIVMSG`, or the name of a numeric
reply, such as `RPL_WELCOME`. Always present. reply, such as `RPL_WELCOME`. Always present.
- `code`: the number of a numeric reply, such as `1` for `RPL_WELCOME`. Absent - `code`: the number of a numeric reply, such as `1` for `RPL_WELCOME`. Absent
on other messages. on other messages.
- `id`: the message's UUID, assigned by the server. - `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. - `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. - `to`: the target, such as a channel name starting with `#` or a nick.
- `params`: other parameters, in order, as an array of strings. `KICK` events - `params`: an array of strings with the message's other parameters, in order.
and many numeric replies have it. The server ignores `params` sent by a `KICK` events and many numeric replies have it. The HTTP API ignores `params`
client. sent by a client.
- `body`: an array of strings. It is never a bare string. In a `PRIVMSG` or - `body`: an array of strings. It is never a bare string. In a `PRIVMSG` or
`NOTICE`, each string is one line of text. `NOTICE`, each string is one line of text.
- `ts`: when the server stored the message, as an RFC 3339 time in UTC, such as - `ts`: the time the server stored the message. It is an RFC 3339 time in UTC,
`2026-10-27T12:00:00.123Z`. such as `2026-10-27T12:00:00.123Z`.
- `meta`: an object of extra data, such as a hashcash stamp in `meta.hashcash`. - `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 A hashcash stamp is a short string that takes a computer some work to make. A
channel with mode `+H` needs one in every `PRIVMSG`. The server relays the `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 `meta` of a `PRIVMSG` or `NOTICE` unchanged. It drops the `meta` of every
other command. other command.
A `PRIVMSG` as the IRC listener sends it, and as the HTTP API sends it without Here is one `PRIVMSG` as the IRC listener sends it and as the HTTP API sends it.
`id`, `ts` and `meta`: The JSON leaves out `id`, `ts` and `meta`:
``` ```
IRC: :alice!alice@* PRIVMSG #general :hello world IRC: :alice!alice@* PRIVMSG #general :hello world
JSON: {"command": "PRIVMSG", "from": "alice", "to": "#general", "body": ["hello world"]} JSON: {"command": "PRIVMSG", "from": "alice", "to": "#general", "body": ["hello world"]}
``` ```
A numeric reply has its name in `command` and its number in `code`. This `353` In the JSON, a numeric reply has its name in `command` and its number in `code`.
reply lists the members of `#general`, again without `id`, `ts` and `meta`: This `353` reply lists the members of `#general`. The JSON again leaves out
`id`, `ts` and `meta`:
``` ```
{"command": "RPL_NAMREPLY", "code": 353, "from": "neoirc", "to": "alice", {"command": "RPL_NAMREPLY", "code": 353, "from": "neoirc", "to": "alice",
"params": ["=", "#general"], "body": ["@op1!op1@host1 alice!alice@host2"]} "params": ["=", "#general"], "body": ["@op1!op1@host1 alice!alice@host2"]}
``` ```
A `body` with two strings is a two-line message: A `PRIVMSG` whose `body` has two strings is a two-line message:
``` ```
{"command": "PRIVMSG", "to": "#general", "body": ["line one", "line two"]} {"command": "PRIVMSG", "to": "#general", "body": ["line one", "line two"]}
@@ -57,7 +63,8 @@ A `body` with two strings is a two-line message:
Commands, in `commands/`: Commands, in `commands/`:
- `PRIVMSG.json`: a message to a channel or a user. - `PRIVMSG.json`: a message to a channel or a user.
- `NOTICE.json`: a notice. A client must not answer a notice automatically. - `NOTICE.json`: a notice, which is a message that a client must not answer
automatically.
- `JOIN.json`: a user joined a channel. - `JOIN.json`: a user joined a channel.
- `PART.json`: a user left a channel. - `PART.json`: a user left a channel.
- `QUIT.json`: a user left the server. - `QUIT.json`: a user left the server.
@@ -68,13 +75,13 @@ Commands, in `commands/`:
- `PING.json`: a check that the server still answers. - `PING.json`: a check that the server still answers.
- `PONG.json`: the answer to a `PING`. - `PONG.json`: the answer to a `PING`.
- `PUBKEY.json`: an announcement of a user's public signing key. The server has - `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). no such command. It answers a `PUBKEY` with `ERR_UNKNOWNCOMMAND` (`421`).
Numeric replies, in `numerics/`: Numeric replies, in `numerics/`:
- `001.json`: `RPL_WELCOME`, which welcomes a new user. - `001.json`: `RPL_WELCOME`, which welcomes a new user.
- `002.json`: `RPL_YOURHOST`, the server's name and version. - `002.json`: `RPL_YOURHOST`, the server's name and version.
- `003.json`: `RPL_CREATED`, the date the server started. - `003.json`: `RPL_CREATED`, which says when the server was created.
- `004.json`: `RPL_MYINFO`, the server's version and its lists of modes. - `004.json`: `RPL_MYINFO`, the server's version and its lists of modes.
- `322.json`: `RPL_LIST`, one channel in the answer to `LIST`. - `322.json`: `RPL_LIST`, one channel in the answer to `LIST`.
- `323.json`: `RPL_LISTEND`, the end of the answer to `LIST`. - `323.json`: `RPL_LISTEND`, the end of the answer to `LIST`.
@@ -90,67 +97,70 @@ Numeric replies, in `numerics/`:
- `442.json`: `ERR_NOTONCHANNEL`, the user is not on that channel. - `442.json`: `ERR_NOTONCHANNEL`, the user is not on that channel.
- `482.json`: `ERR_CHANOPRIVSNEEDED`, the user is not a channel operator. - `482.json`: `ERR_CHANOPRIVSNEEDED`, the user is not a channel operator.
## Where the files differ from the server ## Where the files differ from the HTTP API
Where a schema file and the server disagree, the server is right: Where a schema file and the HTTP API disagree, the HTTP API is right:
- `message.json` and the numeric files put the three-digit code in `command` and - `message.json` and the files in `numerics/` put the three-digit code in
have no `code` field. The server puts the reply's name in `command` and its `command`. They have no `code` field. The HTTP API puts the reply's name in
number in `code`. `command` and its number in `code`.
- `message.json` allows an object as `body`. The server reads only arrays of - `message.json` allows an object as `body`. The HTTP API reads only arrays of
strings. A `PRIVMSG` with an object gets `ERR_NOTEXTTOSEND` (412). 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 `*` - `message.json` says `to` is a channel or a nick. A `WALLOPS` message has `*`
there. there.
- `message.json` allows `meta` on every message. The server keeps `meta` only on - `message.json` allows `meta` on every message. The HTTP API keeps a client's
a `PRIVMSG` or `NOTICE`. `meta` only on a `PRIVMSG` or `NOTICE`.
- `message.json` describes signature fields in `meta`. The server never checks a - `message.json` describes signature fields in `meta`. The server never checks a
signature. signature.
- `message.json` does not describe `meta.hashcash`. The server reads it in a - `message.json` does not describe `meta.hashcash`. The HTTP API reads it in a
`PRIVMSG` to a channel with mode `+H`. `PRIVMSG` to a channel with mode `+H`.
- `JOIN.json` has no `body`. A client's `JOIN` can carry the channel key in - `JOIN.json` has no `body`. A client's `JOIN` can carry the channel key in
`body`. When a user joins, the server's `JOIN` event repeats the channel name `body`. When a user joins, the `JOIN` event repeats the channel name in
there. `body`.
- `JOIN.json` allows a channel name of any length. The server allows at most 63 - `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` characters after the `#`. It answers a longer name with `ERR_NOSUCHCHANNEL`
(403). (`403`).
- `KICK.json` puts the kicked nick in `params`. The server reads a client's - `KICK.json` puts the kicked nick in `params`. The HTTP API reads a client's
`KICK` from `body`: the nick, then an optional reason. `KICK` from `body`: the nick, then an optional reason.
- `MODE.json` puts the mode change in `params`. The server uses `body` instead. - `MODE.json` puts the mode change in `params`. The HTTP API uses `body`
A client sends the change and any value as separate strings, such as `"+o"` instead. A client sends the change and any value as separate strings, such as
and `"bob"`. The server's `MODE` event has one string such as `"+o bob"`, or `"+o"` and `"bob"`. In a `MODE` event, `body` holds the change as one string,
the change and its value as two strings, such as `"+k"` and `"secret"`. such as `"+o bob"`, or as two strings, such as `"+k"` and `"secret"`.
- `NOTICE.json` allows `*` as `to`. The server answers that with - `NOTICE.json` allows `*` as `to`. The server answers a `NOTICE` to `*` with
`ERR_NOSUCHNICK` (401). `ERR_NOSUCHNICK` (`401`).
- `NOTICE.json` has an example notice from the server to `*`. The server sends - `NOTICE.json` has an example notice from the server to `*`. The server sends
no such notice. no such notice.
- `PING.json` and `PONG.json` describe a token that the `PONG` repeats. The - `PING.json` and `PONG.json` describe a token that the `PONG` repeats. The HTTP
server ignores the `body` of a `PING`. It answers the HTTP request itself with API ignores the `body` of a `PING`. Its answer is the HTTP response
`{"command": "PONG", "from": "<server name>"}`. `{"command": "PONG", "from": "<server name>"}`.
- `PING.json` and `PONG.json` also describe messages between servers. The server - `PING.json` and `PONG.json` also describe messages between servers. The server
does not connect to other IRC servers. does not connect to other IRC servers.
- `PUBKEY.json` describes a command the server does not have. - `PUBKEY.json` describes a command the server does not have.
- `QUIT.json` says only the server sends `QUIT`. A client can send it too, to - `QUIT.json` says that `QUIT` comes only from the server. A client can send it
end its session. The client's `QUIT` can carry a reason in `body`. 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. Over the HTTP - `TOPIC.json` says a `TOPIC` without a `body` queries the topic. The HTTP API
API a `TOPIC` without a `body` gets `ERR_NEEDMOREPARAMS` (461). answers such a `TOPIC` with `ERR_NEEDMOREPARAMS` (`461`).
- `TOPIC.json` says an empty array clears the topic. The server answers `[]` - `TOPIC.json` says an empty array clears the topic. The HTTP API answers `[]`
with `ERR_NEEDMOREPARAMS` (461). A `body` of `[""]` clears the topic. with `ERR_NEEDMOREPARAMS` (`461`). A `body` of `[""]` clears the topic.
- `003.json` describes the server's creation date. The server sends the date it - `003.json` describes the server's creation date. The HTTP API sends the date
last started. the server last started.
- `004.json` has example mode lists. The server sends an empty string as the - `004.json` has example mode lists. The HTTP API sends an empty string as the
user modes and `ikmnostl` as the channel modes. user modes and `ikmnostl` as the channel modes.
- `322.json` says `LIST` gets one reply per channel. The server leaves out each - `322.json` says `LIST` gets one reply per channel. The server leaves out a
secret channel that the user is not on. secret channel, one with mode `+s`, unless the user is on it.
- `353.json` says `body` lists nicks. Over the HTTP API the server lists - `332.json` says `RPL_TOPIC` answers a `TOPIC` query. The HTTP API has no such
`nick!user@host` in place of each nick, such as `@op1!op1@host1`. 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 - `353.json` says the first parameter marks a channel as public, private or
secret. The server always sends `=`, the mark for a public channel. secret. The server always sends `=`, the mark for a public channel.
- `376.json` gives the text as `End of /MOTD command`. The server adds a full - `376.json` gives the text as `End of /MOTD command`. The HTTP API adds a full
stop. stop.
- `401.json` gives the text as `No such nick/channel`. For a `PRIVMSG` or - `401.json` gives the text as `No such nick/channel`. The server sends
`NOTICE` to an unknown nick, the server sends `No such nick`. `No such nick` in answer to a `PRIVMSG` or `NOTICE` to an unknown nick.
- `433.json` has an example with `*` as `to`. Over the HTTP API the server sends - `433.json` has an example with `*` as `to`. The HTTP API sends `433` only in
`433` only in answer to a `NICK`, with the user's nick as `to`. answer to a `NICK`, with the user's nick as `to`.
The server also accepts some messages that the files do not allow, such as a The HTTP API also accepts some messages that the files do not allow, such as a
channel name without its `#`. `JOIN` whose `to` has no `#`.