1 Commits
Author SHA1 Message Date
clawbot 4e960a61ee 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 12:16:02 +00:00
44 changed files with 487 additions and 2532 deletions
+5 -8
View File
@@ -643,10 +643,8 @@ Send a message to a channel or user. This is the primary messaging command.
message echoed back via the queue).
- If `to` is a bare nick, the message is a DM. The server fans out to the
recipient and the sender (so all of the sender's clients see the DM).
- A `body` that is not an array of strings, or a `meta` that is not an object,
gets HTTP 400 `invalid request body`, and nothing is relayed. A missing or
empty `body` gets ERR_NOTEXTTOSEND (412). A missing `to` gets ERR_NORECIPIENT
(411).
- `body` must be a non-empty array of strings; otherwise the server sends
ERR_NOTEXTTOSEND (412). A missing `to` gets ERR_NORECIPIENT (411).
- Only channel members can send to a channel. A non-member, a banned user, and
on a `+m` channel a user with neither `+o` nor `+v`, get ERR_CANNOTSENDTOCHAN
(404).
@@ -1238,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","ow","Hbiklmnostv"]}` |
| `004` | RPL_MYINFO | After session creation or login, on MOTD | `{"command":"RPL_MYINFO","code":4,"to":"alice","params":["neoirc","0.1","","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"]}` |
@@ -1751,15 +1749,14 @@ command). **Numeric replies**, for success and for errors, are delivered through
the message queue (see [Numeric Reply Codes](#numeric-reply-codes-s2c-only)); a
command that has none, such as a successful `PRIVMSG`, is answered by the HTTP
response alone, and `PING`'s `PONG` is the HTTP response body. HTTP error codes
(4xx/5xx) are reserved for transport-level problems: a malformed request (400),
(4xx/5xx) are reserved for transport-level problems: malformed JSON (400),
missing/invalid auth cookies (401), and server errors (500).
**HTTP errors (transport-level only):**
| Status | Error | When |
| ------ | ---------------------- | ------------------------------------------------------------------------------------------------- |
| ------ | ---------------------- | ------------------------------------------------------- |
| 400 | `invalid request body` | Malformed JSON, or a body over `MAX_MESSAGE_SIZE` bytes |
| 400 | `invalid request body` | A `PRIVMSG` or `NOTICE` whose `body` is not an array of strings, or whose `meta` is not an object |
| 400 | `command required` | Empty command |
| 401 | `not registered` | Missing or invalid auth cookie |
| 500 | `internal error` | Server-side failure |
+79 -141
View File
@@ -34,30 +34,13 @@ type App struct {
// Run creates and runs the CLI application.
func 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,
ui: NewUI(),
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.SetStatus(app.nick, "", "disconnected")
app.ui.AddStatus(
"Welcome to neoirc-cli — an IRC-style client",
@@ -67,9 +50,12 @@ func run(ui *UI) error {
"[white] to begin, " +
"or [yellow]/help[white] for commands",
)
}()
return app.ui.Run()
err := app.ui.Run()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
func (a *App) handleInput(text string) {
@@ -108,7 +94,7 @@ func (a *App) handleInput(text string) {
})
if err != nil {
a.ui.AddStatus(
"[red]Send error: " + escape(err.Error()),
"[red]Send error: " + err.Error(),
)
}
}
@@ -166,7 +152,7 @@ func (a *App) dispatchInfoCommand(cmd, args string) {
a.cmdWhois(args)
default:
a.ui.AddStatus(
"[red]Unknown command: " + escape(cmd),
"[red]Unknown command: " + cmd,
)
}
}
@@ -182,7 +168,7 @@ func (a *App) cmdConnect(serverURL string) {
serverURL = strings.TrimRight(serverURL, "/")
a.ui.AddStatus("Connecting to " + escape(serverURL) + "...")
a.ui.AddStatus("Connecting to " + serverURL + "...")
a.mu.Lock()
nick := a.nick
@@ -192,37 +178,29 @@ func (a *App) cmdConnect(serverURL string) {
resp, err := client.CreateSession(nick)
if err != nil {
a.ui.AddStatus(
"[red]Connection failed: " + escape(err.Error()),
)
a.ui.AddStatus(fmt.Sprintf(
"[red]Connection failed: %v", err,
))
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(
"[green]Connected! Nick: %s, Session: %d",
escape(resp.Nick), resp.ID,
resp.Nick, resp.ID,
))
a.ui.SetStatus(resp.Nick, "", "connected")
go a.pollLoop(stop)
a.stopPoll = make(chan struct{})
go a.pollLoop()
}
func (a *App) cmdNick(nick string) {
@@ -244,7 +222,7 @@ func (a *App) cmdNick(nick string) {
a.mu.Unlock()
a.ui.AddStatus(
"Nick set to " + escape(nick) +
"Nick set to " + nick +
" (will be used on connect)",
)
@@ -256,9 +234,9 @@ func (a *App) cmdNick(nick string) {
Body: []string{nick},
})
if err != nil {
a.ui.AddStatus(
"[red]Nick change failed: " + escape(err.Error()),
)
a.ui.AddStatus(fmt.Sprintf(
"[red]Nick change failed: %v", err,
))
return
}
@@ -269,7 +247,7 @@ func (a *App) cmdNick(nick string) {
a.mu.Unlock()
a.ui.SetStatus(nick, target, "connected")
a.ui.AddStatus("Nick changed to " + escape(nick))
a.ui.AddStatus("Nick changed to " + nick)
}
func (a *App) cmdJoin(channel string) {
@@ -297,9 +275,9 @@ func (a *App) cmdJoin(channel string) {
err := a.client.JoinChannel(channel)
if err != nil {
a.ui.AddStatus(
"[red]Join failed: " + escape(err.Error()),
)
a.ui.AddStatus(fmt.Sprintf(
"[red]Join failed: %v", err,
))
return
}
@@ -337,9 +315,9 @@ func (a *App) cmdPart(channel string) {
err := a.client.PartChannel(channel)
if err != nil {
a.ui.AddStatus(
"[red]Part failed: " + escape(err.Error()),
)
a.ui.AddStatus(fmt.Sprintf(
"[red]Part failed: %v", err,
))
return
}
@@ -384,9 +362,9 @@ func (a *App) cmdMsg(args string) {
Body: []string{text},
})
if err != nil {
a.ui.AddStatus(
"[red]Send failed: " + escape(err.Error()),
)
a.ui.AddStatus(fmt.Sprintf(
"[red]Send failed: %v", err,
))
}
}
@@ -402,16 +380,10 @@ func (a *App) cmdQuery(nick string) {
a.mu.Lock()
a.target = nick
myNick := a.nick
connected := a.connected
a.mu.Unlock()
connStatus := "disconnected"
if connected {
connStatus = "connected"
}
a.ui.SwitchToBuffer(nick)
a.ui.SetStatus(myNick, nick, connStatus)
a.ui.SetStatus(myNick, nick, "connected")
}
func (a *App) cmdTopic(args string) {
@@ -445,9 +417,9 @@ func (a *App) cmdTopic(args string) {
Body: []string{args},
})
if err != nil {
a.ui.AddStatus(
"[red]Topic set failed: " + escape(err.Error()),
)
a.ui.AddStatus(fmt.Sprintf(
"[red]Topic set failed: %v", err,
))
}
}
@@ -471,9 +443,9 @@ func (a *App) cmdNames() {
members, err := a.client.GetMembers(target)
if err != nil {
a.ui.AddStatus(
"[red]Names failed: " + escape(err.Error()),
)
a.ui.AddStatus(fmt.Sprintf(
"[red]Names failed: %v", err,
))
return
}
@@ -486,7 +458,7 @@ func (a *App) cmdNames() {
a.ui.AddLine(target, fmt.Sprintf(
"[cyan]*** Members of %s: %s",
escape(target), escape(strings.Join(nicks, " ")),
target, strings.Join(nicks, " "),
))
}
@@ -517,9 +489,9 @@ func (a *App) cmdList() {
channels, err := a.client.ListChannels()
if err != nil {
a.ui.AddStatus(
"[red]List failed: " + escape(err.Error()),
)
a.ui.AddStatus(fmt.Sprintf(
"[red]List failed: %v", err,
))
return
}
@@ -528,7 +500,7 @@ func (a *App) cmdList() {
for _, ch := range channels {
a.ui.AddStatus(fmt.Sprintf(
" %s %s", escape(ch.Name), escape(ch.Topic),
" %s %s", ch.Name, ch.Topic,
))
}
@@ -550,9 +522,9 @@ func (a *App) cmdMotd() {
&api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct
)
if err != nil {
a.ui.AddStatus(
"[red]MOTD failed: " + escape(err.Error()),
)
a.ui.AddStatus(fmt.Sprintf(
"[red]MOTD failed: %v", err,
))
}
}
@@ -588,9 +560,9 @@ func (a *App) cmdWho(args string) {
},
)
if err != nil {
a.ui.AddStatus(
"[red]WHO failed: " + escape(err.Error()),
)
a.ui.AddStatus(fmt.Sprintf(
"[red]WHO failed: %v", err,
))
}
}
@@ -619,9 +591,9 @@ func (a *App) cmdWhois(args string) {
},
)
if err != nil {
a.ui.AddStatus(
"[red]WHOIS failed: " + escape(err.Error()),
)
a.ui.AddStatus(fmt.Sprintf(
"[red]WHOIS failed: %v", err,
))
}
}
@@ -638,37 +610,24 @@ func (a *App) cmdWindow(args string) {
_, _ = fmt.Sscanf(args, "%d", &bufIndex)
a.mu.Lock()
nick := a.nick
connected := a.connected
a.mu.Unlock()
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 = name
nick := a.nick
a.mu.Unlock()
if bufIndex >= 0 && bufIndex < a.ui.BufferCount() {
buf := a.ui.buffers[bufIndex]
if buf.Name != "(status)" {
a.mu.Lock()
a.target = buf.Name
a.mu.Unlock()
a.ui.SetStatus(
nick, name, connStatus,
nick, buf.Name, "connected",
)
} else {
a.mu.Lock()
a.target = ""
a.mu.Unlock()
a.ui.SetStatus(nick, "", connStatus)
a.ui.SetStatus(nick, "", "connected")
}
}
}
@@ -716,23 +675,16 @@ func (a *App) cmdHelp() {
}
}
// 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{}) {
// pollLoop long-polls for messages in the background.
func (a *App) pollLoop() {
for {
a.mu.Lock()
select {
case <-stop:
a.mu.Unlock()
case <-a.stopPoll:
return
default:
}
a.mu.Lock()
client := a.client
lastQID := a.lastQID
a.mu.Unlock()
@@ -750,23 +702,11 @@ func (a *App) pollLoop(stop <-chan struct{}) {
continue
}
// 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.mu.Lock()
a.lastQID = result.LastID
}
a.mu.Unlock()
}
for i := range result.Messages {
a.handleServerMessage(&result.Messages[i])
@@ -824,7 +764,7 @@ func (a *App) handlePrivmsgEvent(
a.ui.AddLine(target, fmt.Sprintf(
"[gray]%s [green]<%s>[white] %s",
timestamp, escape(msg.From), escape(text),
timestamp, msg.From, text,
))
}
@@ -837,7 +777,7 @@ func (a *App) handleJoinEvent(
a.ui.AddLine(msg.To, fmt.Sprintf(
"[gray]%s [yellow]*** %s has joined %s",
timestamp, escape(msg.From), escape(msg.To),
timestamp, msg.From, msg.To,
))
}
@@ -856,11 +796,11 @@ func (a *App) handlePartEvent(
line := fmt.Sprintf(
"[gray]%s [yellow]*** %s has left %s",
timestamp, escape(msg.From), escape(msg.To),
timestamp, msg.From, msg.To,
)
if reason != "" {
line += " (" + escape(reason) + ")"
line += " (" + reason + ")"
}
if msg.From == myNick {
@@ -881,12 +821,12 @@ func (a *App) handleQuitEvent(
if reason != "" {
a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [yellow]*** %s has quit (%s)",
timestamp, escape(msg.From), escape(reason),
timestamp, msg.From, reason,
))
} else {
a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [yellow]*** %s has quit",
timestamp, escape(msg.From),
timestamp, msg.From,
))
}
}
@@ -913,7 +853,7 @@ func (a *App) handleNickEvent(
a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [yellow]*** %s is now known as %s",
timestamp, escape(msg.From), escape(newNick),
timestamp, msg.From, newNick,
))
}
@@ -925,7 +865,7 @@ func (a *App) handleNoticeEvent(
a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [magenta]--%s-- %s",
timestamp, escape(msg.From), escape(text),
timestamp, msg.From, text,
))
}
@@ -941,7 +881,7 @@ func (a *App) handleTopicEvent(
a.ui.AddLine(msg.To, fmt.Sprintf(
"[gray]%s [cyan]*** %s set topic: %s",
timestamp, escape(msg.From), escape(text),
timestamp, msg.From, text,
))
}
@@ -951,12 +891,10 @@ func (a *App) handleDefaultEvent(
lines := msg.BodyLines()
text := strings.Join(lines, " ")
// The brackets around the command are escaped with it, as tview
// reads a command such as "[MODE]" as a tag.
if text != "" {
a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [white]%s %s",
timestamp, escape("["+msg.Command+"]"), escape(text),
"[gray]%s [white][%s] %s",
timestamp, msg.Command, text,
))
}
}
File diff suppressed because it is too large Load Diff
+14 -83
View File
@@ -16,10 +16,7 @@ type Buffer struct {
Unread int
}
// 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.
// UI manages the terminal interface.
type UI struct {
app *tview.Application
messages *tview.TextView
@@ -30,17 +27,7 @@ type UI struct {
buffers []*Buffer
currentBuffer int
// The nick, target and connection state last passed to
// SetStatus.
nick string
target string
connStatus string
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.
@@ -185,32 +172,13 @@ func (ui *UI) SetStatus(
nick, target, connStatus string,
) {
ui.app.QueueUpdateDraw(func() {
ui.nick = nick
ui.target = target
ui.connStatus = connStatus
ui.refreshStatusBar()
ui.renderStatusBar(nick, target, connStatus)
})
}
// 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
// BufferCount returns the number of buffers.
func (ui *UI) BufferCount() int {
return len(ui.buffers)
}
// BufferIndex returns the index of a named buffer.
@@ -235,21 +203,9 @@ func (ui *UI) initMessages() {
ui.messages.SetBorder(false)
}
// escape returns text so that the message window shows it exactly
// as written. It writes each "[" as "[[:]": a "[" followed by
// another "[" never starts a tag, and the tag "[:]" changes
// nothing. tview.Escape is not enough: it does not escape a bracket
// pair holding a symbol such as "|" or "^", and nothing it adds
// stops a tag with a URL, such as "[:::y|z]".
func escape(text string) string {
return strings.ReplaceAll(text, "[", "[[:]")
}
func (ui *UI) initStatusBar() {
// 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 = tview.NewTextView().
SetDynamicColors(true)
ui.statusBar.SetBackgroundColor(tcell.ColorNavy)
ui.statusBar.SetTextColor(tcell.ColorWhite)
}
@@ -270,7 +226,10 @@ func (ui *UI) initInput() {
}
ui.input.SetText("")
ui.callOnInput(text)
if ui.onInput != nil {
ui.onInput(text)
}
})
}
@@ -281,11 +240,10 @@ 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' {
ui.callOnInput("/window " + string(r))
idx := int(r - '0')
ui.SwitchBuffer(idx)
return nil
}
@@ -295,35 +253,8 @@ 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)
}()
}
// refreshStatusBar redraws the status bar with the nick, target
// and connection state last passed to SetStatus.
func (ui *UI) refreshStatusBar() {
ui.renderStatusBar(ui.nick, ui.target, ui.connStatus)
// Placeholder; full refresh needs nick/target context.
}
func (ui *UI) renderStatusBar(
+7 -103
View File
@@ -3,12 +3,8 @@ package config
import (
"errors"
"fmt"
"log/slog"
"math"
"os"
"strconv"
"time"
"github.com/spf13/viper"
"go.uber.org/fx"
@@ -32,8 +28,6 @@ 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
@@ -69,8 +63,6 @@ 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) {
@@ -115,27 +107,29 @@ 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")
@@ -150,93 +144,3 @@ 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
}
+2 -149
View File
@@ -2,7 +2,6 @@ package config_test
import (
"os"
"strings"
"testing"
"time"
@@ -11,8 +10,7 @@ import (
"sneak.berlin/go/neoirc/internal/logger"
)
// loadConfig runs config.New and returns its result.
func loadConfig(t *testing.T) (*config.Config, error) {
func newConfig(t *testing.T) *config.Config {
t.Helper()
globs := &globals.Globals{
@@ -26,15 +24,9 @@ func loadConfig(t *testing.T) (*config.Config, error) {
t.Fatal(err)
}
return config.New(
cfg, err := 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)
}
@@ -42,145 +34,6 @@ 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", "")
+4 -51
View File
@@ -412,7 +412,7 @@ func (hdlr *Handlers) deliverWelcome(
// 004 RPL_MYINFO
hdlr.enqueueNumeric(
ctx, clientID, irc.RplMyInfo, nick,
[]string{srvName, version, service.UserModes, "Hbiklmnostv"},
[]string{srvName, version, "", "ikmnostl"},
"",
)
@@ -994,7 +994,7 @@ func (hdlr *Handlers) dispatchCommand(
hdlr.handlePrivmsg(
writer, request,
sessionID, clientID, nick,
command, target, body, meta,
command, target, body, meta, bodyLines,
)
case irc.CmdJoin:
hdlr.handleJoin(
@@ -1139,18 +1139,8 @@ func (hdlr *Handlers) handlePrivmsg(
nick, command, target string,
body json.RawMessage,
meta json.RawMessage,
bodyLines func() []string,
) {
lines, ok := checkBodyAndMeta(body, meta)
if !ok {
hdlr.respondError(
writer, request,
"invalid request body",
http.StatusBadRequest,
)
return
}
if target == "" {
hdlr.enqueueNumeric(
request.Context(), clientID,
@@ -1165,6 +1155,7 @@ func (hdlr *Handlers) handlePrivmsg(
return
}
lines := bodyLines()
if len(lines) == 0 {
hdlr.enqueueNumeric(
request.Context(), clientID,
@@ -1198,44 +1189,6 @@ func (hdlr *Handlers) handlePrivmsg(
)
}
// checkBodyAndMeta returns the lines in body. It returns false
// when body is present and is not an array of strings, or meta
// is present and is not an object. Each line is decoded as any
// because decoding into []string turns a null line into "". A
// null body or meta decodes without an error into nil.
func checkBodyAndMeta(body, meta json.RawMessage) ([]string, bool) {
var lines []string
if body != nil {
var values []any
err := json.Unmarshal(body, &values)
if err != nil || values == nil {
return nil, false
}
for _, value := range values {
line, isString := value.(string)
if !isString {
return nil, false
}
lines = append(lines, line)
}
}
if meta != nil {
var fields map[string]any
err := json.Unmarshal(meta, &fields)
if err != nil || fields == nil {
return nil, false
}
}
return lines, true
}
// respondIRCError enqueues a numeric error reply, notifies
// the broker, and sends HTTP 200 with {"status":"error"}.
func (hdlr *Handlers) respondIRCError(
+7 -168
View File
@@ -8,12 +8,10 @@ import (
"errors"
"fmt"
"io"
"maps"
"net/http"
"net/http/httptest"
"net/url"
"os"
"reflect"
"slices"
"strconv"
"strings"
@@ -1001,8 +999,6 @@ func TestChannelMessage(t *testing.T) {
}
}
// TestMessageMissingBody checks that a PRIVMSG with no body,
// or an empty one, gets ERR_NOTEXTTOSEND.
func TestMessageMissingBody(t *testing.T) {
t.Parallel()
@@ -1017,26 +1013,21 @@ func TestMessageMissingBody(t *testing.T) {
_, lastID := tserver.pollMessages(token, 0)
for _, request := range []map[string]any{
{commandKey: privmsgCmd, toKey: channel},
{commandKey: privmsgCmd, toKey: channel, bodyKey: []string{}},
} {
status, _ := tserver.sendCommand(token, request)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd, toKey: channel,
})
if status != http.StatusOK {
t.Fatalf("%v: expected 200, got %d", request, status)
t.Fatalf("expected 200, got %d", status)
}
var msgs []map[string]any
msgs, lastID = tserver.pollMessages(token, lastID)
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "412") {
t.Fatalf(
"%v: expected ERR_NOTEXTTOSEND (412), got %v",
request, msgs,
"expected ERR_NOTEXTTOSEND (412), got %v",
msgs,
)
}
}
}
func TestMessageMissingTo(t *testing.T) {
@@ -1065,132 +1056,6 @@ func TestMessageMissingTo(t *testing.T) {
}
}
// TestMessageInvalidBodyOrMeta checks that a PRIVMSG or
// NOTICE, to a channel or a nick, whose body is not an array
// of strings or whose meta is not an object gets 400 and
// reaches nobody.
func TestMessageInvalidBodyOrMeta(t *testing.T) {
t.Parallel()
cases := []struct {
name string
fields map[string]any
}{
{"null line", map[string]any{bodyKey: []any{"hi", nil}}},
{"number line", map[string]any{bodyKey: []any{"hi", 1}}},
{"object line", map[string]any{bodyKey: []any{map[string]any{}}}},
{"null body", map[string]any{bodyKey: nil}},
{"object body", map[string]any{bodyKey: map[string]any{}}},
{"null meta", map[string]any{bodyKey: []string{"hi"}, metaKey: nil}},
{"array meta", map[string]any{bodyKey: []string{"hi"}, metaKey: []string{}}},
{"string meta", map[string]any{bodyKey: []string{"hi"}, metaKey: "hi"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
const channel = "#badmsg"
tserver := newTestServer(t)
aliceToken := tserver.createSession("alice_badmsg")
bobToken := tserver.createSession("bob_badmsg")
tserver.sendCommand(aliceToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(bobToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
_, bobLastID := tserver.pollMessages(bobToken, 0)
for _, command := range []string{privmsgCmd, irc.CmdNotice} {
for _, target := range []string{channel, "bob_badmsg"} {
request := map[string]any{
commandKey: command, toKey: target,
}
maps.Copy(request, tc.fields)
status, result := tserver.sendCommand(
aliceToken, request,
)
if status != http.StatusBadRequest ||
result["error"] != "invalid request body" {
t.Errorf(
"%s to %s: expected 400 invalid "+
"request body, got %d: %v",
command, target, status, result,
)
}
}
}
msgs, _ := tserver.pollMessages(bobToken, bobLastID)
if len(msgs) != 0 {
t.Fatalf("expected no messages, bob got %v", msgs)
}
})
}
}
// TestMessageRelaysBodyAndMeta checks that a PRIVMSG or
// NOTICE, to a channel or a nick, reaches its recipient with
// body and meta as sent.
func TestMessageRelaysBodyAndMeta(t *testing.T) {
t.Parallel()
const channel = "#relaymsg"
tserver := newTestServer(t)
aliceToken := tserver.createSession("alice_relaymsg")
bobToken := tserver.createSession("bob_relaymsg")
tserver.sendCommand(aliceToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(bobToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
_, bobLastID := tserver.pollMessages(bobToken, 0)
body := []any{"line one", ""}
meta := map[string]any{"sig": "abc", "alg": "ed25519"}
for _, command := range []string{privmsgCmd, irc.CmdNotice} {
for _, target := range []string{channel, "bob_relaymsg"} {
status, result := tserver.sendCommand(
aliceToken,
map[string]any{
commandKey: command, toKey: target,
bodyKey: body, metaKey: meta,
},
)
if status != http.StatusOK {
t.Fatalf(
"%s to %s: expected 200, got %d: %v",
command, target, status, result,
)
}
var msgs []map[string]any
msgs, bobLastID = tserver.pollMessages(
bobToken, bobLastID,
)
if len(msgs) != 1 ||
msgs[0][commandKey] != command ||
!reflect.DeepEqual(msgs[0][bodyKey], body) ||
!reflect.DeepEqual(msgs[0][metaKey], meta) {
t.Fatalf(
"%s to %s: bob got %v", command, target, msgs,
)
}
}
}
}
func TestNonMemberCannotSend(t *testing.T) {
t.Parallel()
@@ -6932,32 +6797,6 @@ func TestISUPPORT(t *testing.T) {
}
}
// TestMyInfoListsModes verifies the 004 numeric lists the
// user modes o and w as its third parameter, and the HTTP
// API's channel modes Hbiklmnostv as its fourth.
func TestMyInfoListsModes(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" ||
params[3] != "Hbiklmnostv" {
t.Fatalf(
"004 params %v, want four ending ow Hbiklmnostv",
params,
)
}
}
// TestNonOpCannotSetModes verifies non-operators
// cannot set +i, +s, +k, +l, +b.
func TestNonOpCannotSetModes(t *testing.T) {
+2 -6
View File
@@ -371,7 +371,6 @@ func (c *Conn) handleQuit(msg *Message) {
c.mu.Lock()
c.closed = true
c.quitReason = reason
c.mu.Unlock()
}
@@ -1227,12 +1226,9 @@ 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. Another client can log in to this "+
"session over the HTTP API: POST /api/v1/login "+
"with your nick and this password.",
"Password set. You can reconnect using "+
"PASS <password> with your nick.",
)
}
+4 -9
View File
@@ -69,11 +69,6 @@ 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(
@@ -100,7 +95,6 @@ func newConn(
svc: svc,
serverSfx: srvName,
remoteIP: host,
quitReason: "Connection closed",
}
conn.commands = conn.buildCommandMap()
@@ -284,7 +278,6 @@ 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()
@@ -294,7 +287,9 @@ 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, quitReason)
c.svc.BroadcastQuit(
ctx, sessID, nick, "Connection closed",
)
}
}
@@ -610,7 +605,7 @@ func (c *Conn) deliverWelcome() {
)
c.sendNumeric(
irc.RplMyInfo,
c.serverSfx, "neoirc", service.UserModes, "Himnostv",
c.serverSfx, "neoirc", "", "mnst",
)
c.sendNumeric(
irc.RplIsupport,
+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: Client quit"},
{"QUIT", ":Alice!Alice@* QUIT :Quit: Connection closed"},
} {
capitalAlice.send(step.line)
+1 -133
View File
@@ -4,14 +4,12 @@ import (
"bufio"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"os"
"runtime"
"slices"
"strings"
"sync/atomic"
"testing"
@@ -25,7 +23,6 @@ import (
"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"
)
@@ -489,22 +486,6 @@ 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,112 +856,6 @@ 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
@@ -1250,9 +1125,6 @@ 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()
@@ -1265,11 +1137,7 @@ func TestHandlePassPostRegistration(t *testing.T) {
)
assertContains(
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",
t, lines, "Password set", "password confirmation",
)
}
-6
View File
@@ -1169,12 +1169,6 @@ 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
+82 -85
View File
@@ -1,101 +1,98 @@
# Message schemas
# Message Schemas
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.
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.
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.
## Envelope
## 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`:
Every message is a JSON object with a `command` field. The format maps directly
to IRC wire format:
```
IRC: :alice!alice@* PRIVMSG #general :hello world
JSON: {"command": "PRIVMSG", "from": "alice", "to": "#general", "body": ["hello world"]}
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..."}}
```
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`:
Common fields (see `message.json` for full schema):
```
{"command": "RPL_NAMREPLY", "code": 353, "from": "neoirc", "to": "alice",
"params": ["=", "#general"], "body": ["@op1!op1@host1 alice!alice@host2"]}
```
| 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.) |
A `PRIVMSG` whose `body` has two strings is a two-line message:
**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:
```
{"command": "PRIVMSG", "to": "#general", "body": ["line one", "line two"]}
```
- Multiline messages without escape sequences
- Deterministic canonicalization via RFC 8785 JCS for signing
- Structured data where needed
## Files
## Commands
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`.
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`: 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.
## Numeric Replies
Numeric replies, in `numerics/`:
Three-digit codes for server responses, per IRC convention.
- `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.
### 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.
+4 -14
View File
@@ -2,25 +2,15 @@
"$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. 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.",
"description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.",
"$ref": "../message.json",
"properties": {
"command": { "const": "JOIN" },
"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."
}
"from": { "type": "string", "description": "Nick that joined (S2C)." },
"to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" }
},
"required": ["command", "to"],
"examples": [
{ "command": "JOIN", "to": "#general" },
{ "command": "JOIN", "from": "alice", "to": "#general", "body": ["#general"] }
{ "command": "JOIN", "from": "alice", "to": "#general" }
]
}
+7 -12
View File
@@ -2,33 +2,28 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/KICK.json",
"title": "KICK",
"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.",
"description": "Kick a user from a channel. RFC 1459 §4.2.8.",
"$ref": "../message.json",
"properties": {
"command": { "const": "KICK" },
"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}$"
},
"from": { "type": "string", "description": "Nick that performed the kick." },
"to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" },
"params": {
"type": "array",
"items": { "type": "string" },
"description": "The removed nick. Only the server sends it.",
"description": "Kicked nick. e.g. [\"alice\"].",
"minItems": 1,
"maxItems": 1
},
"body": {
"type": "array",
"items": { "type": "string" },
"description": "From a client: the nick to remove, then an optional reason. From the server: the reason, as one string.",
"minItems": 1
"description": "Optional kick reason.",
"maxItems": 1
}
},
"required": ["command", "to", "body"],
"required": ["command", "to", "params"],
"examples": [
{ "command": "KICK", "to": "#general", "body": ["troll", "Behave"] },
{ "command": "KICK", "from": "op1", "to": "#general", "params": ["troll"], "body": ["Behave"] }
]
}
+9 -10
View File
@@ -2,29 +2,28 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/MODE.json",
"title": "MODE",
"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.",
"description": "Set or query channel/user modes. RFC 1459 §4.2.3.",
"$ref": "../message.json",
"properties": {
"command": { "const": "MODE" },
"from": {
"type": "string",
"description": "The nick that changed the mode. The server sets it."
"description": "Nick that set the mode (S2C only)."
},
"to": {
"type": "string",
"description": "A channel name, which starts with #, or the user's own nick."
"description": "Channel name.",
"pattern": "^#[a-zA-Z0-9_-]+$"
},
"body": {
"params": {
"type": "array",
"items": { "type": "string" },
"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"]]
"description": "Mode string and optional target nick. e.g. [\"+o\", \"alice\"].",
"examples": [["+o", "alice"], ["-m"], ["+v", "bob"]]
}
},
"required": ["command", "to"],
"required": ["command", "to", "params"],
"examples": [
{ "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"] }
{ "command": "MODE", "from": "op1", "to": "#general", "params": ["+o", "alice"] }
]
}
+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 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.",
"description": "Change nickname. C2S: request new nick. S2C: notification of nick change. RFC 1459 §4.1.2.",
"$ref": "../message.json",
"properties": {
"command": { "const": "NICK" },
"from": { "type": "string", "description": "The old nick. The server sets it." },
"from": { "type": "string", "description": "Old nick (S2C)." },
"body": {
"type": "array",
"items": { "type": "string" },
"description": "The new nick, as the first string. The server sends one string.",
"minItems": 1
"description": "New nick (single-element array).",
"minItems": 1,
"maxItems": 1
}
},
"required": ["command", "body"],
"examples": [
{ "command": "NICK", "body": ["newnick"] },
{ "command": "NICK", "from": "oldnick", "body": ["newnick"] }
]
}
+5 -6
View File
@@ -2,21 +2,20 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/NOTICE.json",
"title": "NOTICE",
"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.",
"description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.",
"$ref": "../message.json",
"properties": {
"command": { "const": "NOTICE" },
"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." },
"from": { "type": "string" },
"to": { "type": "string", "description": "Target: #channel, nick, or * (global)." },
"body": {
"type": "array",
"items": { "type": "string" },
"description": "The lines of the notice, one string per line.",
"minItems": 1
"description": "Notice text lines."
}
},
"required": ["command", "to", "body"],
"examples": [
{ "command": "NOTICE", "from": "alice", "to": "#general", "body": ["Back in five minutes"] }
{ "command": "NOTICE", "from": "server.example.com", "to": "*", "body": ["Server restarting in 5 minutes"] }
]
}
+5 -8
View File
@@ -2,20 +2,17 @@
"$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. 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.",
"description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.",
"$ref": "../message.json",
"properties": {
"command": { "const": "PART" },
"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}$"
},
"from": { "type": "string", "description": "Nick that left (S2C)." },
"to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" },
"body": {
"type": "array",
"items": { "type": "string" },
"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."
"description": "Optional part reason.",
"maxItems": 1
}
},
"required": ["command", "to"],
+9 -3
View File
@@ -2,13 +2,19 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PING.json",
"title": "PING",
"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.",
"description": "Keepalive. C2S or S2S. Server responds with PONG. RFC 1459 §4.6.2.",
"$ref": "../message.json",
"properties": {
"command": { "const": "PING" }
"command": { "const": "PING" },
"body": {
"type": "array",
"items": { "type": "string" },
"description": "Opaque token to be echoed in PONG (single-element array).",
"maxItems": 1
}
},
"required": ["command"],
"examples": [
{ "command": "PING" }
{ "command": "PING", "body": ["1707580000"] }
]
}
+10 -4
View File
@@ -2,14 +2,20 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PONG.json",
"title": "PONG",
"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.",
"description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.",
"$ref": "../message.json",
"properties": {
"command": { "const": "PONG" },
"from": { "type": "string", "description": "The server name." }
"from": { "type": "string", "description": "Responding server name." },
"body": {
"type": "array",
"items": { "type": "string" },
"description": "Echoed token from PING (single-element array).",
"maxItems": 1
}
},
"required": ["command", "from"],
"required": ["command"],
"examples": [
{ "command": "PONG", "from": "neoirc" }
{ "command": "PONG", "from": "server.example.com", "body": ["1707580000"] }
]
}
+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": "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.",
"description": "Send a message to a channel or user. C2S: client sends to server. S2C: server relays to recipients. RFC 1459 §4.4.1.",
"$ref": "../message.json",
"properties": {
"command": { "const": "PRIVMSG" },
"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"] },
"from": { "type": "string", "description": "Sender nick (set by server on relay)." },
"to": { "type": "string", "description": "Target: #channel or nick.", "examples": ["#general", "alice"] },
"body": {
"type": "array",
"items": { "type": "string" },
"description": "The lines of the message, one string per line.",
"description": "Message lines. 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"] }
{ "command": "PRIVMSG", "from": "bob", "to": "alice", "body": ["hey"], "meta": { "sig": "base64...", "alg": "ed25519" } }
]
}
+37
View File
@@ -0,0 +1,37 @@
{
"$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": "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.",
"description": "User disconnected. S2C only. RFC 1459 §4.1.6.",
"$ref": "../message.json",
"properties": {
"command": { "const": "QUIT" },
"from": { "type": "string", "description": "The nick that quit. The server sets it." },
"from": { "type": "string", "description": "Nick that quit." },
"body": {
"type": "array",
"items": { "type": "string" },
"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."
"description": "Optional quit reason.",
"maxItems": 1
}
},
"required": ["command"],
"required": ["command", "from"],
"examples": [
{ "command": "QUIT", "body": ["Gone for the day"] },
{ "command": "QUIT", "from": "alice", "body": ["Connection closed"] }
{ "command": "QUIT", "from": "alice", "body": ["Connection reset"] }
]
}
+6 -10
View File
@@ -2,24 +2,20 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/TOPIC.json",
"title": "TOPIC",
"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.",
"description": "Get or set channel topic. C2S: set topic (body present) or query (body absent). S2C: topic change notification. RFC 1459 §4.2.4.",
"$ref": "../message.json",
"properties": {
"command": { "const": "TOPIC" },
"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}$"
},
"from": { "type": "string", "description": "Nick that changed the topic (S2C)." },
"to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" },
"body": {
"type": "array",
"items": { "type": "string" },
"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
"description": "New topic text (single-element array). Empty array clears the topic.",
"maxItems": 1
}
},
"required": ["command", "to", "body"],
"required": ["command", "to"],
"examples": [
{ "command": "TOPIC", "from": "alice", "to": "#general", "body": ["Welcome to the channel"] }
]
+30 -18
View File
@@ -2,55 +2,67 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/message.json",
"title": "IRC Message Envelope",
"description": "Every message in the HTTP API is one JSON object with these fields. Only 'command' is always present.",
"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.).",
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid",
"description": "The message's UUID, which the server assigns. Present on every message the server queues for a client."
"description": "Server-assigned message UUID. Present on all server-originated messages."
},
"command": {
"type": "string",
"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."
"description": "IRC command name (PRIVMSG, JOIN, NICK, etc.) or three-digit numeric reply code (001, 353, 433, etc.).",
"examples": ["PRIVMSG", "JOIN", "001", "353", "433"]
},
"from": {
"type": "string",
"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."
"description": "Source — nick for user messages, server name for server messages. Equivalent to IRC prefix."
},
"to": {
"type": "string",
"description": "The target: a channel name, such as #general, or a nick. A WALLOPS has * here."
"description": "Target — channel (#name) or nick. Equivalent to first IRC parameter for most commands."
},
"params": {
"type": "array",
"items": { "type": "string" },
"description": "The message's other parameters, in order. KICK events and many numeric replies have it. The server ignores it from a client."
"description": "Additional parameters (used primarily by numeric replies). Equivalent to IRC middle parameters."
},
"body": {
"oneOf": [
{
"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."
"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."
},
"ts": {
"type": "string",
"format": "date-time",
"description": "The time the server stored the message, as an RFC 3339 time in UTC."
"description": "Server-assigned timestamp (ISO 8601). Not present in original IRC; added for HTTP transport."
},
"meta": {
"type": "object",
"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.",
"description": "Extensible metadata. Used for signatures (meta.sig, meta.alg), hashes (meta.hash), and client extensions.",
"properties": {
"hashcash": {
"sig": {
"type": "string",
"description": "A hashcash stamp. A PRIVMSG to a channel with mode +H needs one."
"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...')."
}
},
"additionalProperties": true
+5 -18
View File
@@ -2,45 +2,32 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/001.json",
"title": "001 RPL_WELCOME",
"description": "Welcomes the user. Sent after a session is created, after a login, and in answer to MOTD. RFC 2812 §5.1.",
"description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "RPL_WELCOME"
},
"code": {
"const": 1
},
"from": {
"type": "string",
"description": "The server name."
"const": "001"
},
"to": {
"type": "string",
"description": "The user's nick."
"description": "Target nick."
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "One string: 'Welcome to the network, ' and the user's nick.",
"minItems": 1,
"maxItems": 1
"description": "Welcome text lines."
}
},
"required": [
"command",
"code",
"from",
"to",
"body"
],
"examples": [
{
"command": "RPL_WELCOME",
"code": 1,
"from": "neoirc",
"command": "001",
"to": "alice",
"body": [
"Welcome to the network, alice"
+6 -20
View File
@@ -2,48 +2,34 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/002.json",
"title": "002 RPL_YOURHOST",
"description": "The server's name and version. Sent right after RPL_WELCOME. RFC 2812 §5.1.",
"description": "Server host info sent after session creation. RFC 2812 \u00a75.1.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "RPL_YOURHOST"
},
"code": {
"const": 2
},
"from": {
"type": "string",
"description": "The server name."
"const": "002"
},
"to": {
"type": "string",
"description": "The user's nick."
"type": "string"
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "One string: 'Your host is ', the server name, ', running version ' and the version.",
"minItems": 1,
"maxItems": 1
"description": "Host info lines."
}
},
"required": [
"command",
"code",
"from",
"to",
"body"
],
"examples": [
{
"command": "RPL_YOURHOST",
"code": 2,
"from": "neoirc",
"command": "002",
"to": "alice",
"body": [
"Your host is neoirc, running version dev"
"Your host is neoirc.example.com, running version 0.1.0"
]
}
]
+5 -19
View File
@@ -2,45 +2,31 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/003.json",
"title": "003 RPL_CREATED",
"description": "The date the server last started. Sent right after RPL_YOURHOST. RFC 2812 §5.1.",
"description": "Server creation date. RFC 2812 \u00a75.1.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "RPL_CREATED"
},
"code": {
"const": 3
},
"from": {
"type": "string",
"description": "The server name."
"const": "003"
},
"to": {
"type": "string",
"description": "The user's nick."
"type": "string"
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "One string: 'This server was created ' and the start date as YYYY-MM-DD.",
"minItems": 1,
"maxItems": 1
"description": "Text lines."
}
},
"required": [
"command",
"code",
"from",
"to",
"body"
],
"examples": [
{
"command": "RPL_CREATED",
"code": 3,
"from": "neoirc",
"command": "003",
"to": "alice",
"body": [
"This server was created 2026-02-01"
+10 -37
View File
@@ -2,64 +2,37 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/004.json",
"title": "004 RPL_MYINFO",
"description": "The server's name, its version, and its lists of user and channel modes. Sent right after RPL_CREATED. RFC 2812 §5.1.",
"description": "Server info (name, version, available modes). RFC 2812 \u00a75.1.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "RPL_MYINFO"
},
"code": {
"const": 4
},
"from": {
"type": "string",
"description": "The server name."
"const": "004"
},
"to": {
"type": "string",
"description": "The user's nick."
"type": "string"
},
"params": {
"type": "array",
"items": {
"type": "string"
},
"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
"description": "[server_name, version, user_modes, channel_modes]."
}
},
"required": [
"command",
"code",
"from",
"to",
"params",
"body"
"params"
],
"examples": [
{
"command": "RPL_MYINFO",
"code": 4,
"from": "neoirc",
"command": "004",
"to": "alice",
"params": [
"neoirc",
"dev",
"ow",
"Hbiklmnostv"
],
"body": [
""
"neoirc.example.com",
"0.1.0",
"o",
"imnst+ov"
]
}
]
+7 -24
View File
@@ -2,55 +2,38 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/322.json",
"title": "322 RPL_LIST",
"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.",
"description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "RPL_LIST"
},
"code": {
"const": 322
},
"from": {
"type": "string",
"description": "The server name."
"const": "322"
},
"to": {
"type": "string",
"description": "The user's nick."
"type": "string"
},
"params": {
"type": "array",
"items": {
"type": "string"
},
"description": "The channel name and its number of members.",
"minItems": 2,
"maxItems": 2
"description": "[channel, visible_count]."
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "One string: the channel's topic, empty when it has none.",
"minItems": 1,
"maxItems": 1
"description": "Channel topic."
}
},
"required": [
"command",
"code",
"from",
"to",
"params",
"body"
"params"
],
"examples": [
{
"command": "RPL_LIST",
"code": 322,
"from": "neoirc",
"command": "322",
"to": "alice",
"params": [
"#general",
+5 -28
View File
@@ -2,49 +2,26 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/323.json",
"title": "323 RPL_LISTEND",
"description": "The end of the answer to LIST. RFC 1459 §6.2.",
"description": "End of channel list. RFC 1459 \u00a76.2.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "RPL_LISTEND"
},
"code": {
"const": 323
},
"from": {
"type": "string",
"description": "The server name."
"const": "323"
},
"to": {
"type": "string",
"description": "The user's nick."
"type": "string"
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "One string: 'End of /LIST'.",
"minItems": 1,
"description": "End of /LIST",
"maxItems": 1
}
},
"required": [
"command",
"code",
"from",
"to",
"body"
],
"examples": [
{
"command": "RPL_LISTEND",
"code": 323,
"from": "neoirc",
"to": "alice",
"body": [
"End of /LIST"
]
}
"to"
]
}
+6 -22
View File
@@ -2,55 +2,39 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/332.json",
"title": "332 RPL_TOPIC",
"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.",
"description": "Channel topic (sent on JOIN or TOPIC query). RFC 1459 \u00a76.2.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "RPL_TOPIC"
},
"code": {
"const": 332
},
"from": {
"type": "string",
"description": "The server name."
"const": "332"
},
"to": {
"type": "string",
"description": "The user's nick."
"type": "string"
},
"params": {
"type": "array",
"items": {
"type": "string"
},
"description": "The channel name.",
"minItems": 1,
"maxItems": 1
"description": "[channel]."
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "One string: the topic.",
"minItems": 1,
"maxItems": 1
"description": "Topic text."
}
},
"required": [
"command",
"code",
"from",
"to",
"params",
"body"
],
"examples": [
{
"command": "RPL_TOPIC",
"code": 332,
"from": "neoirc",
"command": "332",
"to": "alice",
"params": [
"#general"
+7 -23
View File
@@ -2,62 +2,46 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/353.json",
"title": "353 RPL_NAMREPLY",
"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.",
"description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "RPL_NAMREPLY"
},
"code": {
"const": 353
},
"from": {
"type": "string",
"description": "The server name."
"const": "353"
},
"to": {
"type": "string",
"description": "The user's nick."
"type": "string"
},
"params": {
"type": "array",
"items": {
"type": "string"
},
"description": "'=' and the channel name. The server always sends '='.",
"minItems": 2,
"maxItems": 2
"description": "[channel_type, channel]. channel_type: = (public), * (private), @ (secret)."
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"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
"description": "Space-separated list of nicks. Prefixed with @ for ops, + for voiced."
}
},
"required": [
"command",
"code",
"from",
"to",
"params",
"body"
],
"examples": [
{
"command": "RPL_NAMREPLY",
"code": 353,
"from": "neoirc",
"command": "353",
"to": "alice",
"params": [
"=",
"#general"
],
"body": [
"@op1!op1@host1 alice!alice@host2 +bob!bob@host3"
"@op1 alice bob +voiced1"
]
}
]
+6 -34
View File
@@ -2,62 +2,34 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/366.json",
"title": "366 RPL_ENDOFNAMES",
"description": "The end of a channel's member list. It comes after the RPL_NAMREPLY for the channel. RFC 1459 §6.2.",
"description": "End of NAMES list. RFC 1459 \u00a76.2.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "RPL_ENDOFNAMES"
},
"code": {
"const": 366
},
"from": {
"type": "string",
"description": "The server name."
"const": "366"
},
"to": {
"type": "string",
"description": "The user's nick."
"type": "string"
},
"params": {
"type": "array",
"items": {
"type": "string"
},
"description": "The channel name.",
"minItems": 1,
"maxItems": 1
"description": "[channel]."
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "One string: 'End of /NAMES list'.",
"minItems": 1,
"description": "End of /NAMES list",
"maxItems": 1
}
},
"required": [
"command",
"code",
"from",
"to",
"params",
"body"
],
"examples": [
{
"command": "RPL_ENDOFNAMES",
"code": 366,
"from": "neoirc",
"to": "alice",
"params": [
"#general"
],
"body": [
"End of /NAMES list"
]
}
"params"
]
}
+5 -19
View File
@@ -2,45 +2,31 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/372.json",
"title": "372 RPL_MOTD",
"description": "One line of the message of the day. The server sends one per line, after RPL_MOTDSTART. RFC 2812 §5.1.",
"description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "RPL_MOTD"
},
"code": {
"const": 372
},
"from": {
"type": "string",
"description": "The server name."
"const": "372"
},
"to": {
"type": "string",
"description": "The user's nick."
"type": "string"
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "One string: '- ' and the line.",
"minItems": 1,
"maxItems": 1
"description": "MOTD line text (prefixed with '- ')."
}
},
"required": [
"command",
"code",
"from",
"to",
"body"
],
"examples": [
{
"command": "RPL_MOTD",
"code": 372,
"from": "neoirc",
"command": "372",
"to": "alice",
"body": [
"- Welcome to our server!"
+5 -29
View File
@@ -2,49 +2,25 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/375.json",
"title": "375 RPL_MOTDSTART",
"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.",
"description": "Start of MOTD. RFC 2812 \u00a75.1.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "RPL_MOTDSTART"
},
"code": {
"const": 375
},
"from": {
"type": "string",
"description": "The server name."
"const": "375"
},
"to": {
"type": "string",
"description": "The user's nick."
"type": "string"
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "One string: '- ', the server name and ' Message of the Day -'.",
"minItems": 1,
"maxItems": 1
"description": "Text lines."
}
},
"required": [
"command",
"code",
"from",
"to",
"body"
],
"examples": [
{
"command": "RPL_MOTDSTART",
"code": 375,
"from": "neoirc",
"to": "alice",
"body": [
"- neoirc Message of the Day -"
]
}
"to"
]
}
+5 -28
View File
@@ -2,49 +2,26 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/376.json",
"title": "376 RPL_ENDOFMOTD",
"description": "The end of the message of the day. RFC 2812 §5.1.",
"description": "End of MOTD. RFC 2812 \u00a75.1.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "RPL_ENDOFMOTD"
},
"code": {
"const": 376
},
"from": {
"type": "string",
"description": "The server name."
"const": "376"
},
"to": {
"type": "string",
"description": "The user's nick."
"type": "string"
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "One string: 'End of /MOTD command.'.",
"minItems": 1,
"description": "End of /MOTD command",
"maxItems": 1
}
},
"required": [
"command",
"code",
"from",
"to",
"body"
],
"examples": [
{
"command": "RPL_ENDOFMOTD",
"code": 376,
"from": "neoirc",
"to": "alice",
"body": [
"End of /MOTD command."
]
}
"to"
]
}
+8 -24
View File
@@ -2,61 +2,45 @@
"$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 user has that nick. RFC 1459 §6.1.",
"description": "No such nick/channel. RFC 1459 \u00a76.1.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "ERR_NOSUCHNICK"
},
"code": {
"const": 401
},
"from": {
"type": "string",
"description": "The server name."
"const": "401"
},
"to": {
"type": "string",
"description": "The user's nick."
"type": "string"
},
"params": {
"type": "array",
"items": {
"type": "string"
},
"description": "The nick that was not found.",
"minItems": 1,
"maxItems": 1
"description": "[target_nick]."
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"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,
"description": "No such nick/channel",
"maxItems": 1
}
},
"required": [
"command",
"code",
"from",
"to",
"params",
"body"
"params"
],
"examples": [
{
"command": "ERR_NOSUCHNICK",
"code": 401,
"from": "neoirc",
"command": "401",
"to": "alice",
"params": [
"bob"
],
"body": [
"No such nick"
"No such nick/channel"
]
}
]
+7 -23
View File
@@ -2,55 +2,39 @@
"$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 channel has that name. A JOIN with a channel name that the server does not allow gets it too. RFC 1459 §6.1.",
"description": "No such channel. RFC 1459 \u00a76.1.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "ERR_NOSUCHCHANNEL"
},
"code": {
"const": 403
},
"from": {
"type": "string",
"description": "The server name."
"const": "403"
},
"to": {
"type": "string",
"description": "The user's nick."
"type": "string"
},
"params": {
"type": "array",
"items": {
"type": "string"
},
"description": "The channel name.",
"minItems": 1,
"maxItems": 1
"description": "[channel_name]."
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "One string: 'No such channel'.",
"minItems": 1,
"description": "No such channel",
"maxItems": 1
}
},
"required": [
"command",
"code",
"from",
"to",
"params",
"body"
"params"
],
"examples": [
{
"command": "ERR_NOSUCHCHANNEL",
"code": 403,
"from": "neoirc",
"command": "403",
"to": "alice",
"params": [
"#nonexistent"
+9 -25
View File
@@ -2,58 +2,42 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/433.json",
"title": "433 ERR_NICKNAMEINUSE",
"description": "Another user has the nick. Sent in answer to a NICK. RFC 1459 §6.1.",
"description": "Nickname is already in use. RFC 1459 \u00a76.1.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "ERR_NICKNAMEINUSE"
},
"code": {
"const": 433
},
"from": {
"type": "string",
"description": "The server name."
"const": "433"
},
"to": {
"type": "string",
"description": "The user's nick, which has not changed."
"type": "string"
},
"params": {
"type": "array",
"items": {
"type": "string"
},
"description": "The nick that was asked for.",
"minItems": 1,
"maxItems": 1
"description": "[requested_nick]."
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "One string: 'Nickname is already in use'.",
"minItems": 1,
"description": "Nickname is already in use",
"maxItems": 1
}
},
"required": [
"command",
"code",
"from",
"to",
"params",
"body"
"params"
],
"examples": [
{
"command": "ERR_NICKNAMEINUSE",
"code": 433,
"from": "neoirc",
"to": "alice",
"command": "433",
"to": "*",
"params": [
"bob"
"alice"
],
"body": [
"Nickname is already in use"
+6 -34
View File
@@ -2,62 +2,34 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/442.json",
"title": "442 ERR_NOTONCHANNEL",
"description": "The user is not on that channel. RFC 1459 §6.1.",
"description": "You're not on that channel. RFC 1459 \u00a76.1.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "ERR_NOTONCHANNEL"
},
"code": {
"const": 442
},
"from": {
"type": "string",
"description": "The server name."
"const": "442"
},
"to": {
"type": "string",
"description": "The user's nick."
"type": "string"
},
"params": {
"type": "array",
"items": {
"type": "string"
},
"description": "The channel name.",
"minItems": 1,
"maxItems": 1
"description": "[channel]."
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "One string: 'You're not on that channel'.",
"minItems": 1,
"description": "You're not on that channel",
"maxItems": 1
}
},
"required": [
"command",
"code",
"from",
"to",
"params",
"body"
],
"examples": [
{
"command": "ERR_NOTONCHANNEL",
"code": 442,
"from": "neoirc",
"to": "alice",
"params": [
"#general"
],
"body": [
"You're not on that channel"
]
}
"params"
]
}
+6 -34
View File
@@ -2,62 +2,34 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/482.json",
"title": "482 ERR_CHANOPRIVSNEEDED",
"description": "The user is not a channel operator, and the command needs one. RFC 1459 §6.1.",
"description": "You're not channel operator. RFC 1459 \u00a76.1.",
"$ref": "../message.json",
"properties": {
"command": {
"const": "ERR_CHANOPRIVSNEEDED"
},
"code": {
"const": 482
},
"from": {
"type": "string",
"description": "The server name."
"const": "482"
},
"to": {
"type": "string",
"description": "The user's nick."
"type": "string"
},
"params": {
"type": "array",
"items": {
"type": "string"
},
"description": "The channel name.",
"minItems": 1,
"maxItems": 1
"description": "[channel]."
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "One string: 'You're not channel operator'.",
"minItems": 1,
"description": "You're not channel operator",
"maxItems": 1
}
},
"required": [
"command",
"code",
"from",
"to",
"params",
"body"
],
"examples": [
{
"command": "ERR_CHANOPRIVSNEEDED",
"code": 482,
"from": "neoirc",
"to": "alice",
"params": [
"#general"
],
"body": [
"You're not channel operator"
]
}
"params"
]
}