16 Commits
Author SHA1 Message Date
clawbot 5fe325399b Show square brackets in the neoirc-cli message window as written (closes #233)
check / check (push) Waiting to run
The message window has colour tags on, so tview read square-bracket
text in nicks, channel names, reasons, topics and message text as
tags. It dropped that text or changed the colour of the rest of the
line.

A new escape in internal/cli/ui.go writes each "[" as "[[:]", and
app.go applies it to every part of a line that comes from the user or
the server. tview.Escape is not used: it misses bracket pairs holding
symbols such as "|", and tags with a URL.

Lines for other server commands now show the command, such as
"[MODE]", which tview had read as a tag.

Model: opus-5-5
2026-11-02 08:30:56 +01:00
clawbot 24e8b4d027 Refuse a PRIVMSG or NOTICE whose body or meta has the wrong type (closes #245)
check / check (push) Waiting to run
Over the HTTP API, a PRIVMSG or NOTICE body was checked only by
decoding it into a list of strings. JSON null decodes into an empty
string there, and meta was not checked at all, so a null line, or a
meta that is not an object, reached every recipient.

handlePrivmsg now answers 400 invalid request body, before anything
else, when body is present and is not an array of strings, or meta is
present and is not an object. The check returns the lines it accepted,
and the empty-body check counts those, so body is decoded once. A
missing or empty body still gets ERR_NOTEXTTOSEND. README.md says so.

Model: opus-5-5
2026-11-02 07:39:17 +01:00
clawbot 562ce87245 Keep the connection state in the status bar on /query (closes #235)
check / check (push) Waiting to run
/query set the status bar to [connected] whatever the connection
state. Before /connect, it made the status bar say [connected] while
the client had no session.

/query now reads the connection state with the nick, as /window does,
and shows [connected] only while the client is connected,
[disconnected] otherwise. TestQueryConnectionState types /query bob,
connected and not, and checks the status bar. It names the two status
texts as constants, because the linter asks for one once a third copy
appears.

Model: opus-5-5
2026-11-02 07:10:08 +01:00
clawbot 1e2f0de877 Make the schema files match the server (closes #218)
check / check (push) Waiting to run
Each file in schema/ now describes what the HTTP API on next sends and
accepts. A numeric reply has its name in command and its number in code.
body is always an array of strings. MODE, KICK and JOIN carry their
values in body, and TOPIC needs a non-empty body. PING takes no token,
and its PONG is the HTTP response. A channel name is limited to 63
characters, and the leading # is optional from a client.

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

Model: opus-5-5
2026-11-02 06:24:58 +01:00
clawbot 2b1a49e59b Redraw the status bar when a line arrives in another window (closes #232)
check / check (push) Waiting to run
A line that arrives in a window that is not shown is counted as
unread, but refreshStatusBar did nothing, so the window joined the
status bar's unread list only at the next SetStatus. UI now keeps
the nick, target and connection state last passed to SetStatus, and
refreshStatusBar redraws the status bar with them.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Model: opus-5-5
2026-10-28 00:47:53 +01:00
44 changed files with 2229 additions and 493 deletions
+8 -5
View File
@@ -643,8 +643,10 @@ Send a message to a channel or user. This is the primary messaging command.
message echoed back via the queue). message echoed back via the queue).
- If `to` is a bare nick, the message is a DM. The server fans out to the - 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). recipient and the sender (so all of the sender's clients see the DM).
- `body` must be a non-empty array of strings; otherwise the server sends - A `body` that is not an array of strings, or a `meta` that is not an object,
ERR_NOTEXTTOSEND (412). A missing `to` gets ERR_NORECIPIENT (411). 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).
- Only channel members can send to a channel. A non-member, a banned user, and - 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 on a `+m` channel a user with neither `+o` nor `+v`, get ERR_CANNOTSENDTOCHAN
(404). (404).
@@ -1236,7 +1238,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"]}` | | `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"]}` | | `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"]}` | | `003` | RPL_CREATED | After session creation or login, on MOTD | `{"command":"RPL_CREATED","code":3,"to":"alice","body":["This server was created 2026-02-10"]}` |
| `004` | RPL_MYINFO | After session creation or login, on MOTD | `{"command":"RPL_MYINFO","code":4,"to":"alice","params":["neoirc","0.1","","ikmnostl"]}` | | `004` | RPL_MYINFO | After session creation or login, on MOTD | `{"command":"RPL_MYINFO","code":4,"to":"alice","params":["neoirc","0.1","ow","Hbiklmnostv"]}` |
| `005` | RPL_ISUPPORT | After session creation or login, on MOTD | `{"command":"RPL_BOUNCE","code":5,"to":"alice","params":["CHANTYPES=#","NICKLEN=32","PREFIX=(ov)@+","CHANMODES=b,k,Hl,imnst","NETWORK=neoirc","CASEMAPPING=ascii"],"body":["are supported by this server"]}` | | `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":["+"]}` | | `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"]}` | | `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"]}` |
@@ -1749,14 +1751,15 @@ command). **Numeric replies**, for success and for errors, are delivered through
the message queue (see [Numeric Reply Codes](#numeric-reply-codes-s2c-only)); a 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 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 response alone, and `PING`'s `PONG` is the HTTP response body. HTTP error codes
(4xx/5xx) are reserved for transport-level problems: malformed JSON (400), (4xx/5xx) are reserved for transport-level problems: a malformed request (400),
missing/invalid auth cookies (401), and server errors (500). missing/invalid auth cookies (401), and server errors (500).
**HTTP errors (transport-level only):** **HTTP errors (transport-level only):**
| Status | Error | When | | Status | Error | When |
| ------ | ---------------------- | ------------------------------------------------------- | | ------ | ---------------------- | ------------------------------------------------------------------------------------------------- |
| 400 | `invalid request body` | Malformed JSON, or a body over `MAX_MESSAGE_SIZE` bytes | | 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 | | 400 | `command required` | Empty command |
| 401 | `not registered` | Missing or invalid auth cookie | | 401 | `not registered` | Missing or invalid auth cookie |
| 500 | `internal error` | Server-side failure | | 500 | `internal error` | Server-side failure |
+139 -77
View File
@@ -34,13 +34,30 @@ type App struct {
// Run creates and runs the CLI application. // Run creates and runs the CLI application.
func Run() { 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 app := &App{ //nolint:exhaustruct
ui: NewUI(), ui: ui,
nick: "guest", nick: "guest",
} }
app.ui.OnInput(app.handleInput) app.ui.OnInput(app.handleInput)
app.ui.SetStatus(app.nick, "", "disconnected")
// SetStatus and AddStatus wait for the event loop, which
// app.ui.Run starts below, so they run on a goroutine. The
// nick is read first, as a typed /nick may change app.nick.
nick := app.nick
go func() {
app.ui.SetStatus(nick, "", "disconnected")
app.ui.AddStatus( app.ui.AddStatus(
"Welcome to neoirc-cli — an IRC-style client", "Welcome to neoirc-cli — an IRC-style client",
@@ -50,12 +67,9 @@ func Run() {
"[white] to begin, " + "[white] to begin, " +
"or [yellow]/help[white] for commands", "or [yellow]/help[white] for commands",
) )
}()
err := app.ui.Run() return app.ui.Run()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
} }
func (a *App) handleInput(text string) { func (a *App) handleInput(text string) {
@@ -94,7 +108,7 @@ func (a *App) handleInput(text string) {
}) })
if err != nil { if err != nil {
a.ui.AddStatus( a.ui.AddStatus(
"[red]Send error: " + err.Error(), "[red]Send error: " + escape(err.Error()),
) )
} }
} }
@@ -152,7 +166,7 @@ func (a *App) dispatchInfoCommand(cmd, args string) {
a.cmdWhois(args) a.cmdWhois(args)
default: default:
a.ui.AddStatus( a.ui.AddStatus(
"[red]Unknown command: " + cmd, "[red]Unknown command: " + escape(cmd),
) )
} }
} }
@@ -168,7 +182,7 @@ func (a *App) cmdConnect(serverURL string) {
serverURL = strings.TrimRight(serverURL, "/") serverURL = strings.TrimRight(serverURL, "/")
a.ui.AddStatus("Connecting to " + serverURL + "...") a.ui.AddStatus("Connecting to " + escape(serverURL) + "...")
a.mu.Lock() a.mu.Lock()
nick := a.nick nick := a.nick
@@ -178,29 +192,37 @@ func (a *App) cmdConnect(serverURL string) {
resp, err := client.CreateSession(nick) resp, err := client.CreateSession(nick)
if err != nil { if err != nil {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(
"[red]Connection failed: %v", err, "[red]Connection failed: " + escape(err.Error()),
)) )
return 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.mu.Lock()
a.client = client a.client = client
a.nick = resp.Nick a.nick = resp.Nick
a.connected = true a.connected = true
a.lastQID = 0 a.lastQID = 0
if a.stopPoll != nil {
close(a.stopPoll)
}
stop := make(chan struct{})
a.stopPoll = stop
a.mu.Unlock() a.mu.Unlock()
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
"[green]Connected! Nick: %s, Session: %d", "[green]Connected! Nick: %s, Session: %d",
resp.Nick, resp.ID, escape(resp.Nick), resp.ID,
)) ))
a.ui.SetStatus(resp.Nick, "", "connected") a.ui.SetStatus(resp.Nick, "", "connected")
a.stopPoll = make(chan struct{}) go a.pollLoop(stop)
go a.pollLoop()
} }
func (a *App) cmdNick(nick string) { func (a *App) cmdNick(nick string) {
@@ -222,7 +244,7 @@ func (a *App) cmdNick(nick string) {
a.mu.Unlock() a.mu.Unlock()
a.ui.AddStatus( a.ui.AddStatus(
"Nick set to " + nick + "Nick set to " + escape(nick) +
" (will be used on connect)", " (will be used on connect)",
) )
@@ -234,9 +256,9 @@ func (a *App) cmdNick(nick string) {
Body: []string{nick}, Body: []string{nick},
}) })
if err != nil { if err != nil {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(
"[red]Nick change failed: %v", err, "[red]Nick change failed: " + escape(err.Error()),
)) )
return return
} }
@@ -247,7 +269,7 @@ func (a *App) cmdNick(nick string) {
a.mu.Unlock() a.mu.Unlock()
a.ui.SetStatus(nick, target, "connected") a.ui.SetStatus(nick, target, "connected")
a.ui.AddStatus("Nick changed to " + nick) a.ui.AddStatus("Nick changed to " + escape(nick))
} }
func (a *App) cmdJoin(channel string) { func (a *App) cmdJoin(channel string) {
@@ -275,9 +297,9 @@ func (a *App) cmdJoin(channel string) {
err := a.client.JoinChannel(channel) err := a.client.JoinChannel(channel)
if err != nil { if err != nil {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(
"[red]Join failed: %v", err, "[red]Join failed: " + escape(err.Error()),
)) )
return return
} }
@@ -315,9 +337,9 @@ func (a *App) cmdPart(channel string) {
err := a.client.PartChannel(channel) err := a.client.PartChannel(channel)
if err != nil { if err != nil {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(
"[red]Part failed: %v", err, "[red]Part failed: " + escape(err.Error()),
)) )
return return
} }
@@ -362,9 +384,9 @@ func (a *App) cmdMsg(args string) {
Body: []string{text}, Body: []string{text},
}) })
if err != nil { if err != nil {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(
"[red]Send failed: %v", err, "[red]Send failed: " + escape(err.Error()),
)) )
} }
} }
@@ -380,10 +402,16 @@ func (a *App) cmdQuery(nick string) {
a.mu.Lock() a.mu.Lock()
a.target = nick a.target = nick
myNick := a.nick myNick := a.nick
connected := a.connected
a.mu.Unlock() a.mu.Unlock()
connStatus := "disconnected"
if connected {
connStatus = "connected"
}
a.ui.SwitchToBuffer(nick) a.ui.SwitchToBuffer(nick)
a.ui.SetStatus(myNick, nick, "connected") a.ui.SetStatus(myNick, nick, connStatus)
} }
func (a *App) cmdTopic(args string) { func (a *App) cmdTopic(args string) {
@@ -417,9 +445,9 @@ func (a *App) cmdTopic(args string) {
Body: []string{args}, Body: []string{args},
}) })
if err != nil { if err != nil {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(
"[red]Topic set failed: %v", err, "[red]Topic set failed: " + escape(err.Error()),
)) )
} }
} }
@@ -443,9 +471,9 @@ func (a *App) cmdNames() {
members, err := a.client.GetMembers(target) members, err := a.client.GetMembers(target)
if err != nil { if err != nil {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(
"[red]Names failed: %v", err, "[red]Names failed: " + escape(err.Error()),
)) )
return return
} }
@@ -458,7 +486,7 @@ func (a *App) cmdNames() {
a.ui.AddLine(target, fmt.Sprintf( a.ui.AddLine(target, fmt.Sprintf(
"[cyan]*** Members of %s: %s", "[cyan]*** Members of %s: %s",
target, strings.Join(nicks, " "), escape(target), escape(strings.Join(nicks, " ")),
)) ))
} }
@@ -489,9 +517,9 @@ func (a *App) cmdList() {
channels, err := a.client.ListChannels() channels, err := a.client.ListChannels()
if err != nil { if err != nil {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(
"[red]List failed: %v", err, "[red]List failed: " + escape(err.Error()),
)) )
return return
} }
@@ -500,7 +528,7 @@ func (a *App) cmdList() {
for _, ch := range channels { for _, ch := range channels {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
" %s %s", ch.Name, ch.Topic, " %s %s", escape(ch.Name), escape(ch.Topic),
)) ))
} }
@@ -522,9 +550,9 @@ func (a *App) cmdMotd() {
&api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct &api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct
) )
if err != nil { if err != nil {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(
"[red]MOTD failed: %v", err, "[red]MOTD failed: " + escape(err.Error()),
)) )
} }
} }
@@ -560,9 +588,9 @@ func (a *App) cmdWho(args string) {
}, },
) )
if err != nil { if err != nil {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(
"[red]WHO failed: %v", err, "[red]WHO failed: " + escape(err.Error()),
)) )
} }
} }
@@ -591,9 +619,9 @@ func (a *App) cmdWhois(args string) {
}, },
) )
if err != nil { if err != nil {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(
"[red]WHOIS failed: %v", err, "[red]WHOIS failed: " + escape(err.Error()),
)) )
} }
} }
@@ -610,24 +638,37 @@ func (a *App) cmdWindow(args string) {
_, _ = fmt.Sscanf(args, "%d", &bufIndex) _, _ = fmt.Sscanf(args, "%d", &bufIndex)
a.ui.SwitchBuffer(bufIndex)
a.mu.Lock() a.mu.Lock()
nick := a.nick nick := a.nick
connected := a.connected
a.mu.Unlock() a.mu.Unlock()
if bufIndex >= 0 && bufIndex < a.ui.BufferCount() { connStatus := "disconnected"
buf := a.ui.buffers[bufIndex] if connected {
if buf.Name != "(status)" { 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.mu.Lock()
a.target = buf.Name a.target = name
a.mu.Unlock() a.mu.Unlock()
a.ui.SetStatus( a.ui.SetStatus(
nick, buf.Name, "connected", nick, name, connStatus,
) )
} else { } else {
a.ui.SetStatus(nick, "", "connected") a.mu.Lock()
a.target = ""
a.mu.Unlock()
a.ui.SetStatus(nick, "", connStatus)
} }
} }
} }
@@ -675,16 +716,23 @@ func (a *App) cmdHelp() {
} }
} }
// pollLoop long-polls for messages in the background. // pollLoop long-polls for messages in the background until stop is
func (a *App) pollLoop() { // closed. A later /connect closes stop under a.mu as it replaces the
// client and the cursor, so the loop checks stop under a.mu too. A
// stopped loop then neither polls with the new client nor moves the
// new cursor.
func (a *App) pollLoop(stop <-chan struct{}) {
for { for {
a.mu.Lock()
select { select {
case <-a.stopPoll: case <-stop:
a.mu.Unlock()
return return
default: default:
} }
a.mu.Lock()
client := a.client client := a.client
lastQID := a.lastQID lastQID := a.lastQID
a.mu.Unlock() a.mu.Unlock()
@@ -702,12 +750,24 @@ func (a *App) pollLoop() {
continue continue
} }
if result.LastID > 0 { // A poll sent before stop was closed answers for the earlier
// connection.
a.mu.Lock() a.mu.Lock()
a.lastQID = result.LastID
select {
case <-stop:
a.mu.Unlock() a.mu.Unlock()
return
default:
} }
if result.LastID > 0 {
a.lastQID = result.LastID
}
a.mu.Unlock()
for i := range result.Messages { for i := range result.Messages {
a.handleServerMessage(&result.Messages[i]) a.handleServerMessage(&result.Messages[i])
} }
@@ -764,7 +824,7 @@ func (a *App) handlePrivmsgEvent(
a.ui.AddLine(target, fmt.Sprintf( a.ui.AddLine(target, fmt.Sprintf(
"[gray]%s [green]<%s>[white] %s", "[gray]%s [green]<%s>[white] %s",
timestamp, msg.From, text, timestamp, escape(msg.From), escape(text),
)) ))
} }
@@ -777,7 +837,7 @@ func (a *App) handleJoinEvent(
a.ui.AddLine(msg.To, fmt.Sprintf( a.ui.AddLine(msg.To, fmt.Sprintf(
"[gray]%s [yellow]*** %s has joined %s", "[gray]%s [yellow]*** %s has joined %s",
timestamp, msg.From, msg.To, timestamp, escape(msg.From), escape(msg.To),
)) ))
} }
@@ -796,11 +856,11 @@ func (a *App) handlePartEvent(
line := fmt.Sprintf( line := fmt.Sprintf(
"[gray]%s [yellow]*** %s has left %s", "[gray]%s [yellow]*** %s has left %s",
timestamp, msg.From, msg.To, timestamp, escape(msg.From), escape(msg.To),
) )
if reason != "" { if reason != "" {
line += " (" + reason + ")" line += " (" + escape(reason) + ")"
} }
if msg.From == myNick { if msg.From == myNick {
@@ -821,12 +881,12 @@ func (a *App) handleQuitEvent(
if reason != "" { if reason != "" {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [yellow]*** %s has quit (%s)", "[gray]%s [yellow]*** %s has quit (%s)",
timestamp, msg.From, reason, timestamp, escape(msg.From), escape(reason),
)) ))
} else { } else {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [yellow]*** %s has quit", "[gray]%s [yellow]*** %s has quit",
timestamp, msg.From, timestamp, escape(msg.From),
)) ))
} }
} }
@@ -853,7 +913,7 @@ func (a *App) handleNickEvent(
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [yellow]*** %s is now known as %s", "[gray]%s [yellow]*** %s is now known as %s",
timestamp, msg.From, newNick, timestamp, escape(msg.From), escape(newNick),
)) ))
} }
@@ -865,7 +925,7 @@ func (a *App) handleNoticeEvent(
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [magenta]--%s-- %s", "[gray]%s [magenta]--%s-- %s",
timestamp, msg.From, text, timestamp, escape(msg.From), escape(text),
)) ))
} }
@@ -881,7 +941,7 @@ func (a *App) handleTopicEvent(
a.ui.AddLine(msg.To, fmt.Sprintf( a.ui.AddLine(msg.To, fmt.Sprintf(
"[gray]%s [cyan]*** %s set topic: %s", "[gray]%s [cyan]*** %s set topic: %s",
timestamp, msg.From, text, timestamp, escape(msg.From), escape(text),
)) ))
} }
@@ -891,10 +951,12 @@ func (a *App) handleDefaultEvent(
lines := msg.BodyLines() lines := msg.BodyLines()
text := strings.Join(lines, " ") 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 != "" { if text != "" {
a.ui.AddStatus(fmt.Sprintf( a.ui.AddStatus(fmt.Sprintf(
"[gray]%s [white][%s] %s", "[gray]%s [white]%s %s",
timestamp, msg.Command, text, timestamp, escape("["+msg.Command+"]"), escape(text),
)) ))
} }
} }
File diff suppressed because it is too large Load Diff
+83 -14
View File
@@ -16,7 +16,10 @@ type Buffer struct {
Unread int Unread int
} }
// UI manages the terminal interface. // UI manages the terminal interface. Most of its methods wait for
// the tview event loop to run their update. Code that runs on that
// loop, such as a key handler, must not call them: it would wait
// for itself.
type UI struct { type UI struct {
app *tview.Application app *tview.Application
messages *tview.TextView messages *tview.TextView
@@ -27,7 +30,17 @@ type UI struct {
buffers []*Buffer buffers []*Buffer
currentBuffer int currentBuffer int
// The nick, target and connection state last passed to
// SetStatus.
nick string
target string
connStatus string
onInput func(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. // NewUI creates the tview-based IRC-like UI.
@@ -172,13 +185,32 @@ func (ui *UI) SetStatus(
nick, target, connStatus string, nick, target, connStatus string,
) { ) {
ui.app.QueueUpdateDraw(func() { ui.app.QueueUpdateDraw(func() {
ui.renderStatusBar(nick, target, connStatus) ui.nick = nick
ui.target = target
ui.connStatus = connStatus
ui.refreshStatusBar()
}) })
} }
// BufferCount returns the number of buffers. // BufferName returns the name of the buffer at index bufIndex,
func (ui *UI) BufferCount() int { // and whether there is such a buffer.
return len(ui.buffers) func (ui *UI) BufferName(bufIndex int) (string, bool) {
var (
name string
found bool
)
ui.app.QueueUpdate(func() {
if bufIndex < 0 || bufIndex >= len(ui.buffers) {
return
}
name = ui.buffers[bufIndex].Name
found = true
})
return name, found
} }
// BufferIndex returns the index of a named buffer. // BufferIndex returns the index of a named buffer.
@@ -203,9 +235,21 @@ func (ui *UI) initMessages() {
ui.messages.SetBorder(false) 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() { func (ui *UI) initStatusBar() {
ui.statusBar = tview.NewTextView(). // Colour tags stay off. With them on, tview reads text in
SetDynamicColors(true) // square brackets, such as "[connected]" or part of a nick,
// as a tag and drops it.
ui.statusBar = tview.NewTextView()
ui.statusBar.SetBackgroundColor(tcell.ColorNavy) ui.statusBar.SetBackgroundColor(tcell.ColorNavy)
ui.statusBar.SetTextColor(tcell.ColorWhite) ui.statusBar.SetTextColor(tcell.ColorWhite)
} }
@@ -226,10 +270,7 @@ func (ui *UI) initInput() {
} }
ui.input.SetText("") ui.input.SetText("")
ui.callOnInput(text)
if ui.onInput != nil {
ui.onInput(text)
}
}) })
} }
@@ -240,10 +281,11 @@ 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') ui.callOnInput("/window " + string(r))
ui.SwitchBuffer(idx)
return nil return nil
} }
@@ -253,8 +295,35 @@ 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() { func (ui *UI) refreshStatusBar() {
// Placeholder; full refresh needs nick/target context. ui.renderStatusBar(ui.nick, ui.target, ui.connStatus)
} }
func (ui *UI) renderStatusBar( func (ui *UI) renderStatusBar(
+3 -3
View File
@@ -191,9 +191,9 @@ func parseInt(key string, minimum int, value *int) error {
return nil return nil
} }
// parseFloat sets *value to the setting key read as a decimal number. It // parseFloat sets *value to the setting key read as a floating-point
// returns an error when the value does not parse or is not a finite // number. It returns an error when the value does not parse or is not a
// number above zero. // finite number above zero.
func parseFloat(key string, value *float64) error { func parseFloat(key string, value *float64) error {
raw := viper.GetString(key) raw := viper.GetString(key)
+10 -8
View File
@@ -78,26 +78,28 @@ func TestUnsetSettingsKeepDefaults(t *testing.T) {
} }
func TestValidSettingsAreUsed(t *testing.T) { func TestValidSettingsAreUsed(t *testing.T) {
t.Setenv("PORT", "9000") // Each whole number is the lowest value its setting allows, and 1ns
t.Setenv("MAX_MESSAGE_SIZE", "1024") // is the shortest duration allowed.
t.Setenv("PORT", "0")
t.Setenv("MAX_MESSAGE_SIZE", "1")
t.Setenv("NEOIRC_HASHCASH_BITS", "0") t.Setenv("NEOIRC_HASHCASH_BITS", "0")
t.Setenv("LOGIN_RATE_BURST", "10") t.Setenv("LOGIN_RATE_BURST", "1")
t.Setenv("LOGIN_RATE_LIMIT", "0.5") t.Setenv("LOGIN_RATE_LIMIT", "0.5")
t.Setenv("DEBUG", "true") t.Setenv("DEBUG", "true")
t.Setenv("MAINTENANCE_MODE", "true") t.Setenv("MAINTENANCE_MODE", "true")
t.Setenv("MESSAGE_MAX_AGE", "24h") t.Setenv("MESSAGE_MAX_AGE", "1ns")
t.Setenv("QUEUE_MAX_AGE", "1h30m") t.Setenv("QUEUE_MAX_AGE", "1h30m")
t.Setenv("SESSION_IDLE_TIMEOUT", "45m") t.Setenv("SESSION_IDLE_TIMEOUT", "45m")
want := settings{ want := settings{
Port: 9000, Port: 0,
MaxMessageSize: 1024, MaxMessageSize: 1,
HashcashBits: 0, HashcashBits: 0,
LoginRateBurst: 10, LoginRateBurst: 1,
LoginRateLimit: 0.5, LoginRateLimit: 0.5,
Debug: true, Debug: true,
MaintenanceMode: true, MaintenanceMode: true,
MessageMaxAge: "24h", MessageMaxAge: "1ns",
QueueMaxAge: "1h30m", QueueMaxAge: "1h30m",
SessionIdleTimeout: "45m", SessionIdleTimeout: "45m",
} }
+51 -4
View File
@@ -412,7 +412,7 @@ func (hdlr *Handlers) deliverWelcome(
// 004 RPL_MYINFO // 004 RPL_MYINFO
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplMyInfo, nick, ctx, clientID, irc.RplMyInfo, nick,
[]string{srvName, version, "", "ikmnostl"}, []string{srvName, version, service.UserModes, "Hbiklmnostv"},
"", "",
) )
@@ -994,7 +994,7 @@ func (hdlr *Handlers) dispatchCommand(
hdlr.handlePrivmsg( hdlr.handlePrivmsg(
writer, request, writer, request,
sessionID, clientID, nick, sessionID, clientID, nick,
command, target, body, meta, bodyLines, command, target, body, meta,
) )
case irc.CmdJoin: case irc.CmdJoin:
hdlr.handleJoin( hdlr.handleJoin(
@@ -1139,8 +1139,18 @@ func (hdlr *Handlers) handlePrivmsg(
nick, command, target string, nick, command, target string,
body json.RawMessage, body json.RawMessage,
meta 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 == "" { if target == "" {
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
request.Context(), clientID, request.Context(), clientID,
@@ -1155,7 +1165,6 @@ func (hdlr *Handlers) handlePrivmsg(
return return
} }
lines := bodyLines()
if len(lines) == 0 { if len(lines) == 0 {
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
request.Context(), clientID, request.Context(), clientID,
@@ -1189,6 +1198,44 @@ 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 // respondIRCError enqueues a numeric error reply, notifies
// the broker, and sends HTTP 200 with {"status":"error"}. // the broker, and sends HTTP 200 with {"status":"error"}.
func (hdlr *Handlers) respondIRCError( func (hdlr *Handlers) respondIRCError(
+168 -7
View File
@@ -8,10 +8,12 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"maps"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"os" "os"
"reflect"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@@ -999,6 +1001,8 @@ 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) { func TestMessageMissingBody(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1013,21 +1017,26 @@ func TestMessageMissingBody(t *testing.T) {
_, lastID := tserver.pollMessages(token, 0) _, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{ for _, request := range []map[string]any{
commandKey: privmsgCmd, toKey: channel, {commandKey: privmsgCmd, toKey: channel},
}) {commandKey: privmsgCmd, toKey: channel, bodyKey: []string{}},
} {
status, _ := tserver.sendCommand(token, request)
if status != http.StatusOK { if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status) t.Fatalf("%v: expected 200, got %d", request, status)
} }
msgs, _ := tserver.pollMessages(token, lastID) var msgs []map[string]any
msgs, lastID = tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "412") { if !findNumeric(msgs, "412") {
t.Fatalf( t.Fatalf(
"expected ERR_NOTEXTTOSEND (412), got %v", "%v: expected ERR_NOTEXTTOSEND (412), got %v",
msgs, request, msgs,
) )
} }
}
} }
func TestMessageMissingTo(t *testing.T) { func TestMessageMissingTo(t *testing.T) {
@@ -1056,6 +1065,132 @@ 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) { func TestNonMemberCannotSend(t *testing.T) {
t.Parallel() t.Parallel()
@@ -6797,6 +6932,32 @@ 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 // TestNonOpCannotSetModes verifies non-operators
// cannot set +i, +s, +k, +l, +b. // cannot set +i, +s, +k, +l, +b.
func TestNonOpCannotSetModes(t *testing.T) { func TestNonOpCannotSetModes(t *testing.T) {
+6 -2
View File
@@ -371,6 +371,7 @@ func (c *Conn) handleQuit(msg *Message) {
c.mu.Lock() c.mu.Lock()
c.closed = true c.closed = true
c.quitReason = reason
c.mu.Unlock() c.mu.Unlock()
} }
@@ -1226,9 +1227,12 @@ func (c *Conn) handlePassPostReg(
return return
} }
// The IRC listener cannot add a connection to an existing
// session; only the HTTP API login can.
c.sendFromServer("NOTICE", c.currentNick(), c.sendFromServer("NOTICE", c.currentNick(),
"Password set. You can reconnect using "+ "Password set. Another client can log in to this "+
"PASS <password> with your nick.", "session over the HTTP API: POST /api/v1/login "+
"with your nick and this password.",
) )
} }
+9 -4
View File
@@ -69,6 +69,11 @@ type Conn struct {
lastQueueID int64 lastQueueID int64
closed bool closed bool
killed 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( func newConn(
@@ -95,6 +100,7 @@ func newConn(
svc: svc, svc: svc,
serverSfx: srvName, serverSfx: srvName,
remoteIP: host, remoteIP: host,
quitReason: "Connection closed",
} }
conn.commands = conn.buildCommandMap() conn.commands = conn.buildCommandMap()
@@ -278,6 +284,7 @@ func (c *Conn) cleanup(ctx context.Context) {
wasKilled := c.killed wasKilled := c.killed
sessID := c.sessionID sessID := c.sessionID
nick := c.nick nick := c.nick
quitReason := c.quitReason
c.closed = true c.closed = true
c.mu.Unlock() c.mu.Unlock()
@@ -287,9 +294,7 @@ func (c *Conn) cleanup(ctx context.Context) {
// KILL has already sent the QUIT and deleted the // KILL has already sent the QUIT and deleted the
// session. // session.
if !wasKilled { if !wasKilled {
c.svc.BroadcastQuit( c.svc.BroadcastQuit(ctx, sessID, nick, quitReason)
ctx, sessID, nick, "Connection closed",
)
} }
} }
@@ -605,7 +610,7 @@ func (c *Conn) deliverWelcome() {
) )
c.sendNumeric( c.sendNumeric(
irc.RplMyInfo, irc.RplMyInfo,
c.serverSfx, "neoirc", "", "mnst", c.serverSfx, "neoirc", service.UserModes, "Himnostv",
) )
c.sendNumeric( c.sendNumeric(
irc.RplIsupport, irc.RplIsupport,
+1 -1
View File
@@ -316,7 +316,7 @@ func TestRelayNickDiffersOnlyInCase(t *testing.T) {
{"JOIN #case", ":Alice!Alice@* JOIN #case"}, {"JOIN #case", ":Alice!Alice@* JOIN #case"},
{"NICK Alicia", ":Alice!Alice@* NICK Alicia"}, {"NICK Alicia", ":Alice!Alice@* NICK Alicia"},
{"NICK Alice", ":Alicia!Alicia@* NICK Alice"}, // back for QUIT {"NICK Alice", ":Alicia!Alicia@* NICK Alice"}, // back for QUIT
{"QUIT", ":Alice!Alice@* QUIT :Quit: Connection closed"}, {"QUIT", ":Alice!Alice@* QUIT :Quit: Client quit"},
} { } {
capitalAlice.send(step.line) capitalAlice.send(step.line)
+133 -1
View File
@@ -4,12 +4,14 @@ import (
"bufio" "bufio"
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net" "net"
"os" "os"
"runtime" "runtime"
"slices"
"strings" "strings"
"sync/atomic" "sync/atomic"
"testing" "testing"
@@ -23,6 +25,7 @@ import (
"sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/ircserver" "sneak.berlin/go/neoirc/internal/ircserver"
"sneak.berlin/go/neoirc/internal/logger" "sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/pkg/irc"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -486,6 +489,22 @@ func TestWelcomeContainsNick(t *testing.T) {
} }
} }
// TestMyInfoListsModes checks that RPL_MYINFO lists the user
// modes o and w, then the channel modes MODE accepts on the
// IRC listener. It compares the whole line, so a missing or
// extra mode fails it.
func TestMyInfoListsModes(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
lines := env.dial(t).register("carol")
want := ":test.irc 004 carol test.irc neoirc ow Himnostv"
if !slices.Contains(lines, want) {
t.Errorf("no line %q in %v", want, lines)
}
}
func TestPingPong(t *testing.T) { func TestPingPong(t *testing.T) {
t.Parallel() t.Parallel()
@@ -856,6 +875,112 @@ func TestQuit(t *testing.T) {
assertContains(t, lines, "goodbye", "QUIT reason") 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 // TestStopEndsSessions checks that stopping the server ends
// a registered connection's session before Stop returns, as // a registered connection's session before Stop returns, as
// closing the connection does: the session, its client and // closing the connection does: the session, its client and
@@ -1125,6 +1250,9 @@ func TestAwaySetAndClear(t *testing.T) {
assertContains(t, clearLines, " 305 ", "RPL_UNAWAY") 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) { func TestHandlePassPostRegistration(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1137,7 +1265,11 @@ func TestHandlePassPostRegistration(t *testing.T) {
) )
assertContains( assertContains(
t, lines, "Password set", "password confirmation", t, lines,
" NOTICE passuser :Password set. Another client can log "+
"in to this session over the HTTP API: "+
"POST /api/v1/login with your nick and this password.",
"password confirmation",
) )
} }
+6
View File
@@ -1169,6 +1169,12 @@ func (s *Service) ApplyUserMode(
return s.QueryUserMode(ctx, sessionID) 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 // parseUserModeString parses a user mode string such as
// "+w", "-o" or "+w-o" into the new value of each flag it // "+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 // names, or nil for a flag it does not name; a later letter
+17 -82
View File
@@ -20,7 +20,7 @@ are the fields it can have:
- `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, a unique ID that the server assigns. - `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 numeric reply or a `PONG`.
- `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`: an array of strings with the message's other parameters, in order. - `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` `KICK` events and many numeric replies have it. The HTTP API ignores `params`
@@ -60,29 +60,32 @@ A `PRIVMSG` whose `body` has two strings is a two-line message:
## Files ## Files
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`.
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, which is a message that a client must not answer - `NOTICE.json`: a notice, which is a message that a client must not answer
automatically. automatically.
- `JOIN.json`: a user joined a channel. - `JOIN.json`: joining a channel.
- `PART.json`: a user left a channel. - `PART.json`: leaving a channel.
- `QUIT.json`: a user left the server. - `QUIT.json`: leaving the server.
- `NICK.json`: a user changed nick. - `NICK.json`: changing nick.
- `TOPIC.json`: a channel topic was set. - `TOPIC.json`: setting a channel's topic.
- `MODE.json`: a channel mode was changed. - `MODE.json`: querying or changing a channel's modes or the user's own modes.
- `KICK.json`: a channel operator removed a user from a channel. - `KICK.json`: a channel operator removing a user from a channel.
- `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`, which is the HTTP response to it.
- `PUBKEY.json`: an announcement of a user's public signing key. The server has
no such command. It answers a `PUBKEY` with `ERR_UNKNOWNCOMMAND` (`421`).
Numeric replies, in `numerics/`: Numeric replies, in `numerics/`:
- `001.json`: `RPL_WELCOME`, which welcomes a new user. - `001.json`: `RPL_WELCOME`, which welcomes the 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`, which says when the server was created. - `003.json`: `RPL_CREATED`, the date the server last started.
- `004.json`: `RPL_MYINFO`, the server's version and its lists of modes. - `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`. - `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`.
- `332.json`: `RPL_TOPIC`, a channel's topic. - `332.json`: `RPL_TOPIC`, a channel's topic.
@@ -96,71 +99,3 @@ Numeric replies, in `numerics/`:
- `433.json`: `ERR_NICKNAMEINUSE`, the nick is taken. - `433.json`: `ERR_NICKNAMEINUSE`, the nick is taken.
- `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 HTTP API
Where a schema file and the HTTP API disagree, the HTTP API is right:
- `message.json` and the files in `numerics/` put the three-digit code in
`command`. They have no `code` field. The HTTP API puts the reply's name in
`command` and its number in `code`.
- `message.json` allows an object as `body`. The HTTP API reads only arrays of
strings. A `PRIVMSG` with an object as `body` gets `ERR_NOTEXTTOSEND` (`412`).
- `message.json` says `to` is a channel or a nick. A `WALLOPS` message has `*`
there.
- `message.json` allows `meta` on every message. The HTTP API keeps a client's
`meta` only on a `PRIVMSG` or `NOTICE`.
- `message.json` describes signature fields in `meta`. The server never checks a
signature.
- `message.json` does not describe `meta.hashcash`. The HTTP API reads it in a
`PRIVMSG` to a channel with mode `+H`.
- `JOIN.json` has no `body`. A client's `JOIN` can carry the channel key in
`body`. When a user joins, the `JOIN` event repeats the channel name in
`body`.
- `JOIN.json` allows a channel name of any length. The server allows at most 63
characters after the `#`. It answers a longer name with `ERR_NOSUCHCHANNEL`
(`403`).
- `KICK.json` puts the kicked nick in `params`. The HTTP API reads a client's
`KICK` from `body`: the nick, then an optional reason.
- `MODE.json` puts the mode change in `params`. The HTTP API uses `body`
instead. A client sends the change and any value as separate strings, such as
`"+o"` and `"bob"`. In a `MODE` event, `body` holds the change as one string,
such as `"+o bob"`, or as two strings, such as `"+k"` and `"secret"`.
- `NOTICE.json` allows `*` as `to`. The server answers a `NOTICE` to `*` with
`ERR_NOSUCHNICK` (`401`).
- `NOTICE.json` has an example notice from the server to `*`. The server sends
no such notice.
- `PING.json` and `PONG.json` describe a token that the `PONG` repeats. The HTTP
API ignores the `body` of a `PING`. Its answer is the HTTP response
`{"command": "PONG", "from": "<server name>"}`.
- `PING.json` and `PONG.json` also describe messages between servers. The server
does not connect to other IRC servers.
- `PUBKEY.json` describes a command the server does not have.
- `QUIT.json` says that `QUIT` comes only from the server. A client can send it
too, to end its session. A client's `QUIT` can carry a reason in `body`.
- `TOPIC.json` says a `TOPIC` without a `body` queries the topic. The HTTP API
answers such a `TOPIC` with `ERR_NEEDMOREPARAMS` (`461`).
- `TOPIC.json` says an empty array clears the topic. The HTTP API answers `[]`
with `ERR_NEEDMOREPARAMS` (`461`). A `body` of `[""]` clears the topic.
- `003.json` describes the server's creation date. The HTTP API sends the date
the server last started.
- `004.json` has example mode lists. The HTTP API sends an empty string as the
user modes and `ikmnostl` as the channel modes.
- `322.json` says `LIST` gets one reply per channel. The server leaves out a
secret channel, one with mode `+s`, unless the user is on it.
- `332.json` says `RPL_TOPIC` answers a `TOPIC` query. The HTTP API has no such
query. It sends `RPL_TOPIC` after a client sets the topic, and after a `JOIN`
to a channel that has one.
- `353.json` says `body` lists nicks. The HTTP API lists `nick!user@host` in
place of each nick, such as `@op1!op1@host1`.
- `353.json` says the first parameter marks a channel as public, private or
secret. The server always sends `=`, the mark for a public channel.
- `376.json` gives the text as `End of /MOTD command`. The HTTP API adds a full
stop.
- `401.json` gives the text as `No such nick/channel`. The server sends
`No such nick` in answer to a `PRIVMSG` or `NOTICE` to an unknown nick.
- `433.json` has an example with `*` as `to`. The HTTP API sends `433` only in
answer to a `NICK`, with the user's nick as `to`.
The HTTP API also accepts some messages that the files do not allow, such as a
`JOIN` whose `to` has no `#`.
+14 -4
View File
@@ -2,15 +2,25 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/JOIN.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/JOIN.json",
"title": "JOIN", "title": "JOIN",
"description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.", "description": "Join a channel. A client sends it to join a channel, which the server creates if it does not exist. The server sends it to every member of the channel when a user joins, the new member included. RFC 1459 §4.2.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "JOIN" }, "command": { "const": "JOIN" },
"from": { "type": "string", "description": "Nick that joined (S2C)." }, "from": { "type": "string", "description": "The nick that joined. The server sets it." },
"to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" } "to": {
"type": "string",
"description": "The channel name: # and then 1 to 63 of a-z, A-Z, 0-9, _ and -. The server adds the # if a client leaves it out.",
"pattern": "^#?[a-zA-Z0-9_-]{1,63}$"
},
"body": {
"type": "array",
"items": { "type": "string" },
"description": "From a client: the channel key as the first string, which a channel with mode +k needs. From the server: the channel name. After a login, and for GET /api/v1/state?initChannelState=1, the server queues a JOIN for each of the session's channels. That JOIN has an empty body."
}
}, },
"required": ["command", "to"], "required": ["command", "to"],
"examples": [ "examples": [
{ "command": "JOIN", "from": "alice", "to": "#general" } { "command": "JOIN", "to": "#general" },
{ "command": "JOIN", "from": "alice", "to": "#general", "body": ["#general"] }
] ]
} }
+12 -7
View File
@@ -2,28 +2,33 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/KICK.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/KICK.json",
"title": "KICK", "title": "KICK",
"description": "Kick a user from a channel. RFC 1459 §4.2.8.", "description": "Remove a user from a channel. A client sends it, and only a channel operator may. The server sends it to every member of the channel, the removed user included. RFC 1459 §4.2.8.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "KICK" }, "command": { "const": "KICK" },
"from": { "type": "string", "description": "Nick that performed the kick." }, "from": { "type": "string", "description": "The nick that removed the user. The server sets it." },
"to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" }, "to": {
"type": "string",
"description": "The channel name. The server adds the # if a client leaves it out.",
"pattern": "^#?[a-zA-Z0-9_-]{1,63}$"
},
"params": { "params": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "Kicked nick. e.g. [\"alice\"].", "description": "The removed nick. Only the server sends it.",
"minItems": 1, "minItems": 1,
"maxItems": 1 "maxItems": 1
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "Optional kick reason.", "description": "From a client: the nick to remove, then an optional reason. From the server: the reason, as one string.",
"maxItems": 1 "minItems": 1
} }
}, },
"required": ["command", "to", "params"], "required": ["command", "to", "body"],
"examples": [ "examples": [
{ "command": "KICK", "to": "#general", "body": ["troll", "Behave"] },
{ "command": "KICK", "from": "op1", "to": "#general", "params": ["troll"], "body": ["Behave"] } { "command": "KICK", "from": "op1", "to": "#general", "params": ["troll"], "body": ["Behave"] }
] ]
} }
+10 -9
View File
@@ -2,28 +2,29 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/MODE.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/MODE.json",
"title": "MODE", "title": "MODE",
"description": "Set or query channel/user modes. RFC 1459 §4.2.3.", "description": "Query or change a channel's modes or the user's own modes. A client sends it. The server sends it to every member of the channel when a channel mode changes. A +H or -H sent over the HTTP API is the exception. The server answers it with RPL_CHANNELMODEIS (324) and sends no MODE. RFC 1459 §4.2.3.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "MODE" }, "command": { "const": "MODE" },
"from": { "from": {
"type": "string", "type": "string",
"description": "Nick that set the mode (S2C only)." "description": "The nick that changed the mode. The server sets it."
}, },
"to": { "to": {
"type": "string", "type": "string",
"description": "Channel name.", "description": "A channel name, which starts with #, or the user's own nick."
"pattern": "^#[a-zA-Z0-9_-]+$"
}, },
"params": { "body": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "Mode string and optional target nick. e.g. [\"+o\", \"alice\"].", "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", "alice"], ["-m"], ["+v", "bob"]] "examples": [["+o", "bob"], ["-m"], ["+o bob"], ["+k", "secret"]]
} }
}, },
"required": ["command", "to", "params"], "required": ["command", "to"],
"examples": [ "examples": [
{ "command": "MODE", "from": "op1", "to": "#general", "params": ["+o", "alice"] } { "command": "MODE", "to": "#general", "body": ["+o", "bob"] },
{ "command": "MODE", "from": "op1", "to": "#general", "body": ["+o bob"] },
{ "command": "MODE", "from": "op1", "to": "#general", "body": ["+k", "secret"] }
] ]
} }
+5 -5
View File
@@ -2,21 +2,21 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/NICK.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/NICK.json",
"title": "NICK", "title": "NICK",
"description": "Change nickname. C2S: request new nick. S2C: notification of nick change. RFC 1459 §4.1.2.", "description": "Change nick. A client sends it to change its nick. The server sends it to the user's own clients and to the users who share a channel with the user. RFC 1459 §4.1.2.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "NICK" }, "command": { "const": "NICK" },
"from": { "type": "string", "description": "Old nick (S2C)." }, "from": { "type": "string", "description": "The old nick. The server sets it." },
"body": { "body": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "New nick (single-element array).", "description": "The new nick, as the first string. The server sends one string.",
"minItems": 1, "minItems": 1
"maxItems": 1
} }
}, },
"required": ["command", "body"], "required": ["command", "body"],
"examples": [ "examples": [
{ "command": "NICK", "body": ["newnick"] },
{ "command": "NICK", "from": "oldnick", "body": ["newnick"] } { "command": "NICK", "from": "oldnick", "body": ["newnick"] }
] ]
} }
+6 -5
View File
@@ -2,20 +2,21 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/NOTICE.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/NOTICE.json",
"title": "NOTICE", "title": "NOTICE",
"description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.", "description": "A notice: a message to a channel or a user that a client must not answer automatically. The server relays it as it relays a PRIVMSG. A NOTICE to a channel with mode +H needs no hashcash stamp. RFC 1459 §4.4.2.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "NOTICE" }, "command": { "const": "NOTICE" },
"from": { "type": "string" }, "from": { "type": "string", "description": "The sender's nick. The server sets it." },
"to": { "type": "string", "description": "Target: #channel, nick, or * (global)." }, "to": { "type": "string", "description": "A channel name, which starts with #, or a nick." },
"body": { "body": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "Notice text lines." "description": "The lines of the notice, one string per line.",
"minItems": 1
} }
}, },
"required": ["command", "to", "body"], "required": ["command", "to", "body"],
"examples": [ "examples": [
{ "command": "NOTICE", "from": "server.example.com", "to": "*", "body": ["Server restarting in 5 minutes"] } { "command": "NOTICE", "from": "alice", "to": "#general", "body": ["Back in five minutes"] }
] ]
} }
+8 -5
View File
@@ -2,17 +2,20 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PART.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/PART.json",
"title": "PART", "title": "PART",
"description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.", "description": "Leave a channel. A client sends it to leave. The server sends it to every member of the channel when a user leaves, the leaving user included. RFC 1459 §4.2.2.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "PART" }, "command": { "const": "PART" },
"from": { "type": "string", "description": "Nick that left (S2C)." }, "from": { "type": "string", "description": "The nick that left. The server sets it." },
"to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" }, "to": {
"type": "string",
"description": "The channel name. The server adds the # if a client leaves it out.",
"pattern": "^#?[a-zA-Z0-9_-]{1,63}$"
},
"body": { "body": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "Optional part reason.", "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."
"maxItems": 1
} }
}, },
"required": ["command", "to"], "required": ["command", "to"],
+3 -9
View File
@@ -2,19 +2,13 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PING.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/PING.json",
"title": "PING", "title": "PING",
"description": "Keepalive. C2S or S2S. Server responds with PONG. RFC 1459 §4.6.2.", "description": "A check that the server still answers. A client sends it. The server ignores every field but 'command'. Its answer is a PONG in the HTTP response. RFC 1459 §4.6.2.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "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"], "required": ["command"],
"examples": [ "examples": [
{ "command": "PING", "body": ["1707580000"] } { "command": "PING" }
] ]
} }
+4 -10
View File
@@ -2,20 +2,14 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PONG.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/PONG.json",
"title": "PONG", "title": "PONG",
"description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.", "description": "The answer to a PING. The server sends it as the HTTP response to the PING, with only 'command' and 'from'. A client's PONG gets ERR_UNKNOWNCOMMAND (421). RFC 1459 §4.6.3.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "PONG" }, "command": { "const": "PONG" },
"from": { "type": "string", "description": "Responding server name." }, "from": { "type": "string", "description": "The server name." }
"body": {
"type": "array",
"items": { "type": "string" },
"description": "Echoed token from PING (single-element array).",
"maxItems": 1
}
}, },
"required": ["command"], "required": ["command", "from"],
"examples": [ "examples": [
{ "command": "PONG", "from": "server.example.com", "body": ["1707580000"] } { "command": "PONG", "from": "neoirc" }
] ]
} }
+5 -5
View File
@@ -2,16 +2,16 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PRIVMSG.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/PRIVMSG.json",
"title": "PRIVMSG", "title": "PRIVMSG",
"description": "Send a message to a channel or user. C2S: client sends to server. S2C: server relays to recipients. RFC 1459 §4.4.1.", "description": "A message to a channel or a user. A client sends it. The server relays it to every member of the channel, or to the user, and to the sender's own clients. RFC 1459 §4.4.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "PRIVMSG" }, "command": { "const": "PRIVMSG" },
"from": { "type": "string", "description": "Sender nick (set by server on relay)." }, "from": { "type": "string", "description": "The sender's nick. The server sets it." },
"to": { "type": "string", "description": "Target: #channel or nick.", "examples": ["#general", "alice"] }, "to": { "type": "string", "description": "A channel name, which starts with #, or a nick.", "examples": ["#general", "alice"] },
"body": { "body": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "Message lines. One string per line.", "description": "The lines of the message, one string per line.",
"minItems": 1 "minItems": 1
} }
}, },
@@ -19,6 +19,6 @@
"examples": [ "examples": [
{ "command": "PRIVMSG", "from": "bob", "to": "#general", "body": ["hello world"] }, { "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": "#general", "body": ["line one", "line two"] },
{ "command": "PRIVMSG", "from": "bob", "to": "alice", "body": ["hey"], "meta": { "sig": "base64...", "alg": "ed25519" } } { "command": "PRIVMSG", "from": "bob", "to": "alice", "body": ["hey"] }
] ]
} }
-37
View File
@@ -1,37 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PUBKEY.json",
"title": "PUBKEY",
"description": "Announce or relay a user's public signing key. C2S: client announces key to channel or server. S2C: server relays to channel members. Protocol extension (not in RFC 1459). Body is a structured object (not an array) containing the key material.",
"$ref": "../message.json",
"properties": {
"command": { "const": "PUBKEY" },
"from": { "type": "string", "description": "Nick announcing the key (set by server on relay)." },
"to": {
"type": "string",
"description": "Target: #channel to announce to channel members, or omit for server-wide announcement."
},
"body": {
"type": "object",
"description": "Key material.",
"properties": {
"alg": {
"type": "string",
"description": "Key algorithm.",
"enum": ["ed25519"]
},
"key": {
"type": "string",
"description": "Base64-encoded public key."
}
},
"required": ["alg", "key"],
"additionalProperties": false
}
},
"required": ["command", "body"],
"examples": [
{ "command": "PUBKEY", "from": "alice", "body": { "alg": "ed25519", "key": "base64-encoded-pubkey" } },
{ "command": "PUBKEY", "from": "alice", "to": "#general", "body": { "alg": "ed25519", "key": "base64-encoded-pubkey" } }
]
}
+6 -6
View File
@@ -2,20 +2,20 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/QUIT.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/QUIT.json",
"title": "QUIT", "title": "QUIT",
"description": "User disconnected. S2C only. RFC 1459 §4.1.6.", "description": "A user left the server. A client sends it to end its session. The server sends it to the users who shared a channel with the user who left. RFC 1459 §4.1.6.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "QUIT" }, "command": { "const": "QUIT" },
"from": { "type": "string", "description": "Nick that quit." }, "from": { "type": "string", "description": "The nick that quit. The server sets it." },
"body": { "body": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "Optional quit reason.", "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."
"maxItems": 1
} }
}, },
"required": ["command", "from"], "required": ["command"],
"examples": [ "examples": [
{ "command": "QUIT", "from": "alice", "body": ["Connection reset"] } { "command": "QUIT", "body": ["Gone for the day"] },
{ "command": "QUIT", "from": "alice", "body": ["Connection closed"] }
] ]
} }
+10 -6
View File
@@ -2,20 +2,24 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/TOPIC.json", "$id": "https://sneak.berlin/go/neoirc/schema/commands/TOPIC.json",
"title": "TOPIC", "title": "TOPIC",
"description": "Get or set channel topic. C2S: set topic (body present) or query (body absent). S2C: topic change notification. RFC 1459 §4.2.4.", "description": "Set a channel's topic. A client sends it to set the topic. The server sends it to every member of the channel when the topic changes. RFC 1459 §4.2.4.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "const": "TOPIC" }, "command": { "const": "TOPIC" },
"from": { "type": "string", "description": "Nick that changed the topic (S2C)." }, "from": { "type": "string", "description": "The nick that set the topic. The server sets it." },
"to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" }, "to": {
"type": "string",
"description": "The channel name. The server adds the # if a client leaves it out.",
"pattern": "^#?[a-zA-Z0-9_-]{1,63}$"
},
"body": { "body": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "New topic text (single-element array). Empty array clears the topic.", "description": "The new topic. The server joins a client's strings with spaces, and sends the topic as one string. [\"\"] clears the topic.",
"maxItems": 1 "minItems": 1
} }
}, },
"required": ["command", "to"], "required": ["command", "to", "body"],
"examples": [ "examples": [
{ "command": "TOPIC", "from": "alice", "to": "#general", "body": ["Welcome to the channel"] } { "command": "TOPIC", "from": "alice", "to": "#general", "body": ["Welcome to the channel"] }
] ]
+18 -30
View File
@@ -2,67 +2,55 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/message.json", "$id": "https://sneak.berlin/go/neoirc/schema/message.json",
"title": "IRC Message Envelope", "title": "IRC Message Envelope",
"description": "Base envelope for all messages. Mirrors IRC wire format (RFC 1459/2812) encoded as JSON over HTTP. The 'command' field carries either an IRC command name (PRIVMSG, JOIN, etc.) or a three-digit numeric reply code (001, 353, 433, etc.).", "description": "Every message in the HTTP API is one JSON object with these fields. Only 'command' is always present.",
"type": "object", "type": "object",
"properties": { "properties": {
"id": { "id": {
"type": "string", "type": "string",
"format": "uuid", "format": "uuid",
"description": "Server-assigned message UUID. Present on all server-originated messages." "description": "The message's UUID, which the server assigns. Present on every message the server queues for a client."
}, },
"command": { "command": {
"type": "string", "type": "string",
"description": "IRC command name (PRIVMSG, JOIN, NICK, etc.) or three-digit numeric reply code (001, 353, 433, etc.).", "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", "001", "353", "433"] "examples": ["PRIVMSG", "JOIN", "RPL_WELCOME", "ERR_NICKNAMEINUSE"]
},
"code": {
"type": "integer",
"minimum": 1,
"maximum": 999,
"description": "The number of a numeric reply, such as 1 for RPL_WELCOME. Absent on other messages."
}, },
"from": { "from": {
"type": "string", "type": "string",
"description": "Source — nick for user messages, server name for server messages. Equivalent to IRC prefix." "description": "The sender's nick, or the server name on a numeric reply or a PONG. The server sets it and ignores it from a client."
}, },
"to": { "to": {
"type": "string", "type": "string",
"description": "Target — channel (#name) or nick. Equivalent to first IRC parameter for most commands." "description": "The target: a channel name, such as #general, or a nick. A WALLOPS has * here."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "Additional parameters (used primarily by numeric replies). Equivalent to IRC middle parameters." "description": "The message's other parameters, in order. KICK events and many numeric replies have it. The server ignores it from a client."
}, },
"body": { "body": {
"oneOf": [
{
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "Array of strings (one per line for text messages)." "description": "An array of strings, never a bare string. In a PRIVMSG or NOTICE, each string is one line of text."
},
{
"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": { "ts": {
"type": "string", "type": "string",
"format": "date-time", "format": "date-time",
"description": "Server-assigned timestamp (ISO 8601). Not present in original IRC; added for HTTP transport." "description": "The time the server stored the message, as an RFC 3339 time in UTC."
}, },
"meta": { "meta": {
"type": "object", "type": "object",
"description": "Extensible metadata. Used for signatures (meta.sig, meta.alg), hashes (meta.hash), and client extensions.", "description": "An object of extra data. Every message the server queues for a client has meta, which is {} when there is none. The server keeps a client's meta only on a PRIVMSG or NOTICE, and relays it unchanged.",
"properties": { "properties": {
"sig": { "hashcash": {
"type": "string", "type": "string",
"description": "Base64-encoded cryptographic signature over the canonical message form." "description": "A hashcash stamp. A PRIVMSG to a channel with mode +H needs one."
},
"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 "additionalProperties": true
+18 -5
View File
@@ -2,32 +2,45 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/001.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/001.json",
"title": "001 RPL_WELCOME", "title": "001 RPL_WELCOME",
"description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.", "description": "Welcomes the user. Sent after a session is created, after a login, and in answer to MOTD. RFC 2812 §5.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "001" "const": "RPL_WELCOME"
},
"code": {
"const": 1
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string", "type": "string",
"description": "Target nick." "description": "The user's nick."
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "Welcome text lines." "description": "One string: 'Welcome to the network, ' and the user's nick.",
"minItems": 1,
"maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"body" "body"
], ],
"examples": [ "examples": [
{ {
"command": "001", "command": "RPL_WELCOME",
"code": 1,
"from": "neoirc",
"to": "alice", "to": "alice",
"body": [ "body": [
"Welcome to the network, alice" "Welcome to the network, alice"
+20 -6
View File
@@ -2,34 +2,48 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/002.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/002.json",
"title": "002 RPL_YOURHOST", "title": "002 RPL_YOURHOST",
"description": "Server host info sent after session creation. RFC 2812 \u00a75.1.", "description": "The server's name and version. Sent right after RPL_WELCOME. RFC 2812 §5.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "002" "const": "RPL_YOURHOST"
},
"code": {
"const": 2
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string" "type": "string",
"description": "The user's nick."
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "Host info lines." "description": "One string: 'Your host is ', the server name, ', running version ' and the version.",
"minItems": 1,
"maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"body" "body"
], ],
"examples": [ "examples": [
{ {
"command": "002", "command": "RPL_YOURHOST",
"code": 2,
"from": "neoirc",
"to": "alice", "to": "alice",
"body": [ "body": [
"Your host is neoirc.example.com, running version 0.1.0" "Your host is neoirc, running version dev"
] ]
} }
] ]
+19 -5
View File
@@ -2,31 +2,45 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/003.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/003.json",
"title": "003 RPL_CREATED", "title": "003 RPL_CREATED",
"description": "Server creation date. RFC 2812 \u00a75.1.", "description": "The date the server last started. Sent right after RPL_YOURHOST. RFC 2812 §5.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "003" "const": "RPL_CREATED"
},
"code": {
"const": 3
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string" "type": "string",
"description": "The user's nick."
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "Text lines." "description": "One string: 'This server was created ' and the start date as YYYY-MM-DD.",
"minItems": 1,
"maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"body" "body"
], ],
"examples": [ "examples": [
{ {
"command": "003", "command": "RPL_CREATED",
"code": 3,
"from": "neoirc",
"to": "alice", "to": "alice",
"body": [ "body": [
"This server was created 2026-02-01" "This server was created 2026-02-01"
+37 -10
View File
@@ -2,37 +2,64 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/004.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/004.json",
"title": "004 RPL_MYINFO", "title": "004 RPL_MYINFO",
"description": "Server info (name, version, available modes). RFC 2812 \u00a75.1.", "description": "The server's name, its version, and its lists of user and channel modes. Sent right after RPL_CREATED. RFC 2812 §5.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "004" "const": "RPL_MYINFO"
},
"code": {
"const": 4
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string" "type": "string",
"description": "The user's nick."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "[server_name, version, user_modes, channel_modes]." "description": "The server name, the version, the user modes 'ow' and the channel modes 'Hbiklmnostv'.",
"minItems": 4,
"maxItems": 4
},
"body": {
"type": "array",
"items": {
"type": "string"
},
"description": "One empty string.",
"minItems": 1,
"maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params" "params",
"body"
], ],
"examples": [ "examples": [
{ {
"command": "004", "command": "RPL_MYINFO",
"code": 4,
"from": "neoirc",
"to": "alice", "to": "alice",
"params": [ "params": [
"neoirc.example.com", "neoirc",
"0.1.0", "dev",
"o", "ow",
"imnst+ov" "Hbiklmnostv"
],
"body": [
""
] ]
} }
] ]
+24 -7
View File
@@ -2,38 +2,55 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/322.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/322.json",
"title": "322 RPL_LIST", "title": "322 RPL_LIST",
"description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.", "description": "One channel in the answer to LIST. The answer has one per channel, except a secret channel, one with mode +s, that the user is not on. RFC 1459 §6.2.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "322" "const": "RPL_LIST"
},
"code": {
"const": 322
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string" "type": "string",
"description": "The user's nick."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "[channel, visible_count]." "description": "The channel name and its number of members.",
"minItems": 2,
"maxItems": 2
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "Channel topic." "description": "One string: the channel's topic, empty when it has none.",
"minItems": 1,
"maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params" "params",
"body"
], ],
"examples": [ "examples": [
{ {
"command": "322", "command": "RPL_LIST",
"code": 322,
"from": "neoirc",
"to": "alice", "to": "alice",
"params": [ "params": [
"#general", "#general",
+28 -5
View File
@@ -2,26 +2,49 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/323.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/323.json",
"title": "323 RPL_LISTEND", "title": "323 RPL_LISTEND",
"description": "End of channel list. RFC 1459 \u00a76.2.", "description": "The end of the answer to LIST. RFC 1459 §6.2.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "323" "const": "RPL_LISTEND"
},
"code": {
"const": 323
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string" "type": "string",
"description": "The user's nick."
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "End of /LIST", "description": "One string: 'End of /LIST'.",
"minItems": 1,
"maxItems": 1 "maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"to" "code",
"from",
"to",
"body"
],
"examples": [
{
"command": "RPL_LISTEND",
"code": 323,
"from": "neoirc",
"to": "alice",
"body": [
"End of /LIST"
]
}
] ]
} }
+22 -6
View File
@@ -2,39 +2,55 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/332.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/332.json",
"title": "332 RPL_TOPIC", "title": "332 RPL_TOPIC",
"description": "Channel topic (sent on JOIN or TOPIC query). RFC 1459 \u00a76.2.", "description": "A channel's topic. Sent to a client after it sets the topic, and after it joins a channel that has one. Also sent for each of a session's channels that has one after a login, and for GET /api/v1/state?initChannelState=1. RFC 1459 §6.2.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "332" "const": "RPL_TOPIC"
},
"code": {
"const": 332
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string" "type": "string",
"description": "The user's nick."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "[channel]." "description": "The channel name.",
"minItems": 1,
"maxItems": 1
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "Topic text." "description": "One string: the topic.",
"minItems": 1,
"maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params", "params",
"body" "body"
], ],
"examples": [ "examples": [
{ {
"command": "332", "command": "RPL_TOPIC",
"code": 332,
"from": "neoirc",
"to": "alice", "to": "alice",
"params": [ "params": [
"#general" "#general"
+23 -7
View File
@@ -2,46 +2,62 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/353.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/353.json",
"title": "353 RPL_NAMREPLY", "title": "353 RPL_NAMREPLY",
"description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.", "description": "A channel's members. Sent to a client after it joins a channel, and in answer to NAMES. Also sent for each of a session's channels after a login, and for GET /api/v1/state?initChannelState=1. RFC 1459 §6.2.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "353" "const": "RPL_NAMREPLY"
},
"code": {
"const": 353
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string" "type": "string",
"description": "The user's nick."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "[channel_type, channel]. channel_type: = (public), * (private), @ (secret)." "description": "'=' and the channel name. The server always sends '='.",
"minItems": 2,
"maxItems": 2
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "Space-separated list of nicks. Prefixed with @ for ops, + for voiced." "description": "One string: the members as nick!user@host, separated by spaces. '@' comes before a channel operator and '+' before a voiced member. The host is '*' when the server does not know it.",
"minItems": 1,
"maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params", "params",
"body" "body"
], ],
"examples": [ "examples": [
{ {
"command": "353", "command": "RPL_NAMREPLY",
"code": 353,
"from": "neoirc",
"to": "alice", "to": "alice",
"params": [ "params": [
"=", "=",
"#general" "#general"
], ],
"body": [ "body": [
"@op1 alice bob +voiced1" "@op1!op1@host1 alice!alice@host2 +bob!bob@host3"
] ]
} }
] ]
+34 -6
View File
@@ -2,34 +2,62 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/366.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/366.json",
"title": "366 RPL_ENDOFNAMES", "title": "366 RPL_ENDOFNAMES",
"description": "End of NAMES list. RFC 1459 \u00a76.2.", "description": "The end of a channel's member list. It comes after the RPL_NAMREPLY for the channel. RFC 1459 §6.2.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "366" "const": "RPL_ENDOFNAMES"
},
"code": {
"const": 366
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string" "type": "string",
"description": "The user's nick."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "[channel]." "description": "The channel name.",
"minItems": 1,
"maxItems": 1
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "End of /NAMES list", "description": "One string: 'End of /NAMES list'.",
"minItems": 1,
"maxItems": 1 "maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params" "params",
"body"
],
"examples": [
{
"command": "RPL_ENDOFNAMES",
"code": 366,
"from": "neoirc",
"to": "alice",
"params": [
"#general"
],
"body": [
"End of /NAMES list"
]
}
] ]
} }
+19 -5
View File
@@ -2,31 +2,45 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/372.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/372.json",
"title": "372 RPL_MOTD", "title": "372 RPL_MOTD",
"description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.", "description": "One line of the message of the day. The server sends one per line, after RPL_MOTDSTART. RFC 2812 §5.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "372" "const": "RPL_MOTD"
},
"code": {
"const": 372
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string" "type": "string",
"description": "The user's nick."
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "MOTD line text (prefixed with '- ')." "description": "One string: '- ' and the line.",
"minItems": 1,
"maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"body" "body"
], ],
"examples": [ "examples": [
{ {
"command": "372", "command": "RPL_MOTD",
"code": 372,
"from": "neoirc",
"to": "alice", "to": "alice",
"body": [ "body": [
"- Welcome to our server!" "- Welcome to our server!"
+29 -5
View File
@@ -2,25 +2,49 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/375.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/375.json",
"title": "375 RPL_MOTDSTART", "title": "375 RPL_MOTDSTART",
"description": "Start of MOTD. RFC 2812 \u00a75.1.", "description": "The start of the message of the day. Sent after RPL_LUSERME (255) when the server has a message of the day. RFC 2812 §5.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "375" "const": "RPL_MOTDSTART"
},
"code": {
"const": 375
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string" "type": "string",
"description": "The user's nick."
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "Text lines." "description": "One string: '- ', the server name and ' Message of the Day -'.",
"minItems": 1,
"maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"to" "code",
"from",
"to",
"body"
],
"examples": [
{
"command": "RPL_MOTDSTART",
"code": 375,
"from": "neoirc",
"to": "alice",
"body": [
"- neoirc Message of the Day -"
]
}
] ]
} }
+28 -5
View File
@@ -2,26 +2,49 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/376.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/376.json",
"title": "376 RPL_ENDOFMOTD", "title": "376 RPL_ENDOFMOTD",
"description": "End of MOTD. RFC 2812 \u00a75.1.", "description": "The end of the message of the day. RFC 2812 §5.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "376" "const": "RPL_ENDOFMOTD"
},
"code": {
"const": 376
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string" "type": "string",
"description": "The user's nick."
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "End of /MOTD command", "description": "One string: 'End of /MOTD command.'.",
"minItems": 1,
"maxItems": 1 "maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"to" "code",
"from",
"to",
"body"
],
"examples": [
{
"command": "RPL_ENDOFMOTD",
"code": 376,
"from": "neoirc",
"to": "alice",
"body": [
"End of /MOTD command."
]
}
] ]
} }
+24 -8
View File
@@ -2,45 +2,61 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/401.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/401.json",
"title": "401 ERR_NOSUCHNICK", "title": "401 ERR_NOSUCHNICK",
"description": "No such nick/channel. RFC 1459 \u00a76.1.", "description": "No user has that nick. RFC 1459 §6.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "401" "const": "ERR_NOSUCHNICK"
},
"code": {
"const": 401
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string" "type": "string",
"description": "The user's nick."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "[target_nick]." "description": "The nick that was not found.",
"minItems": 1,
"maxItems": 1
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "No such nick/channel", "description": "One string: 'No such nick' in answer to a PRIVMSG or NOTICE, and 'No such nick/channel' in answer to anything else.",
"minItems": 1,
"maxItems": 1 "maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params" "params",
"body"
], ],
"examples": [ "examples": [
{ {
"command": "401", "command": "ERR_NOSUCHNICK",
"code": 401,
"from": "neoirc",
"to": "alice", "to": "alice",
"params": [ "params": [
"bob" "bob"
], ],
"body": [ "body": [
"No such nick/channel" "No such nick"
] ]
} }
] ]
+23 -7
View File
@@ -2,39 +2,55 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/403.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/403.json",
"title": "403 ERR_NOSUCHCHANNEL", "title": "403 ERR_NOSUCHCHANNEL",
"description": "No such channel. RFC 1459 \u00a76.1.", "description": "No channel has that name. A JOIN with a channel name that the server does not allow gets it too. RFC 1459 §6.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "403" "const": "ERR_NOSUCHCHANNEL"
},
"code": {
"const": 403
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string" "type": "string",
"description": "The user's nick."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "[channel_name]." "description": "The channel name.",
"minItems": 1,
"maxItems": 1
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "No such channel", "description": "One string: 'No such channel'.",
"minItems": 1,
"maxItems": 1 "maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params" "params",
"body"
], ],
"examples": [ "examples": [
{ {
"command": "403", "command": "ERR_NOSUCHCHANNEL",
"code": 403,
"from": "neoirc",
"to": "alice", "to": "alice",
"params": [ "params": [
"#nonexistent" "#nonexistent"
+25 -9
View File
@@ -2,42 +2,58 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/433.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/433.json",
"title": "433 ERR_NICKNAMEINUSE", "title": "433 ERR_NICKNAMEINUSE",
"description": "Nickname is already in use. RFC 1459 \u00a76.1.", "description": "Another user has the nick. Sent in answer to a NICK. RFC 1459 §6.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "433" "const": "ERR_NICKNAMEINUSE"
},
"code": {
"const": 433
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string" "type": "string",
"description": "The user's nick, which has not changed."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "[requested_nick]." "description": "The nick that was asked for.",
"minItems": 1,
"maxItems": 1
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "Nickname is already in use", "description": "One string: 'Nickname is already in use'.",
"minItems": 1,
"maxItems": 1 "maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params" "params",
"body"
], ],
"examples": [ "examples": [
{ {
"command": "433", "command": "ERR_NICKNAMEINUSE",
"to": "*", "code": 433,
"from": "neoirc",
"to": "alice",
"params": [ "params": [
"alice" "bob"
], ],
"body": [ "body": [
"Nickname is already in use" "Nickname is already in use"
+34 -6
View File
@@ -2,34 +2,62 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/442.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/442.json",
"title": "442 ERR_NOTONCHANNEL", "title": "442 ERR_NOTONCHANNEL",
"description": "You're not on that channel. RFC 1459 \u00a76.1.", "description": "The user is not on that channel. RFC 1459 §6.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "442" "const": "ERR_NOTONCHANNEL"
},
"code": {
"const": 442
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string" "type": "string",
"description": "The user's nick."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "[channel]." "description": "The channel name.",
"minItems": 1,
"maxItems": 1
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "You're not on that channel", "description": "One string: 'You're not on that channel'.",
"minItems": 1,
"maxItems": 1 "maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params" "params",
"body"
],
"examples": [
{
"command": "ERR_NOTONCHANNEL",
"code": 442,
"from": "neoirc",
"to": "alice",
"params": [
"#general"
],
"body": [
"You're not on that channel"
]
}
] ]
} }
+34 -6
View File
@@ -2,34 +2,62 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/482.json", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/482.json",
"title": "482 ERR_CHANOPRIVSNEEDED", "title": "482 ERR_CHANOPRIVSNEEDED",
"description": "You're not channel operator. RFC 1459 \u00a76.1.", "description": "The user is not a channel operator, and the command needs one. RFC 1459 §6.1.",
"$ref": "../message.json", "$ref": "../message.json",
"properties": { "properties": {
"command": { "command": {
"const": "482" "const": "ERR_CHANOPRIVSNEEDED"
},
"code": {
"const": 482
},
"from": {
"type": "string",
"description": "The server name."
}, },
"to": { "to": {
"type": "string" "type": "string",
"description": "The user's nick."
}, },
"params": { "params": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "[channel]." "description": "The channel name.",
"minItems": 1,
"maxItems": 1
}, },
"body": { "body": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "You're not channel operator", "description": "One string: 'You're not channel operator'.",
"minItems": 1,
"maxItems": 1 "maxItems": 1
} }
}, },
"required": [ "required": [
"command", "command",
"code",
"from",
"to", "to",
"params" "params",
"body"
],
"examples": [
{
"command": "ERR_CHANOPRIVSNEEDED",
"code": 482,
"from": "neoirc",
"to": "alice",
"params": [
"#general"
],
"body": [
"You're not channel operator"
]
}
] ]
} }