From f8f0b6afbbb50529b1b7dbff2e296d5e882c17af Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 9 Mar 2026 22:21:30 +0100 Subject: [PATCH] refactor: replace HTTP error codes with IRC numeric replies (#56) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Refactors all IRC command handlers to respond with proper IRC numeric replies via the message queue instead of HTTP status codes. HTTP error codes are now reserved exclusively for transport-level concerns: - **401** — missing/invalid auth token - **400** — malformed JSON, empty command - **500** — server errors ## IRC Numerics Implemented ### Success replies (delivered via message queue on success): - **001 RPL_WELCOME** — sent on session creation and login - **331 RPL_NOTOPIC** — channel has no topic (on JOIN) - **332 RPL_TOPIC** — channel topic (on JOIN, TOPIC set) - **353 RPL_NAMREPLY** — channel member list (on JOIN) - **366 RPL_ENDOFNAMES** — end of NAMES list (on JOIN) - **375/372/376** — MOTD (already existed) ### Error replies (delivered via message queue instead of HTTP 4xx): - **401 ERR_NOSUCHNICK** — DM target not found (was HTTP 404) - **403 ERR_NOSUCHCHANNEL** — channel not found / invalid name (was HTTP 404) - **421 ERR_UNKNOWNCOMMAND** — unrecognized command (was HTTP 400) - **432 ERR_ERRONEUSNICKNAME** — invalid nick format (was HTTP 400) - **433 ERR_NICKNAMEINUSE** — nick taken (was HTTP 409) - **442 ERR_NOTONCHANNEL** — not a member of channel (was HTTP 403) - **461 ERR_NEEDMOREPARAMS** — missing required fields (was HTTP 400) ## Database Changes - Added `params` column to messages table for IRC-style parameters - Added `Params` field to `IRCMessage` struct - Updated `InsertMessage` to accept params ## Test Updates - All existing tests updated to expect HTTP 200 + IRC numerics - New tests: `TestWelcomeNumeric`, `TestJoinNumerics` ## Client Impact - CLI and SPA already handle unknown numerics via default event handlers - PRIVMSG/NOTICE success changed from HTTP 201 to HTTP 200 closes https://git.eeqj.de/sneak/chat/issues/54 Co-authored-by: clawbot Co-authored-by: Jeffrey Paul Reviewed-on: https://git.eeqj.de/sneak/chat/pulls/56 Co-authored-by: clawbot Co-committed-by: clawbot --- README.md | 55 +++- internal/db/queries.go | 32 +- internal/db/queries_test.go | 8 +- internal/db/schema/001_initial.sql | 1 + internal/handlers/api.go | 461 +++++++++++++++++++++-------- internal/handlers/api_test.go | 236 +++++++++++++-- internal/handlers/auth.go | 8 +- 7 files changed, 627 insertions(+), 174 deletions(-) diff --git a/README.md b/README.md index 937b233..9a7905d 100644 --- a/README.md +++ b/README.md @@ -845,10 +845,11 @@ the server to the client (never C2S) and use 3-digit string codes in the | `442` | ERR_NOTONCHANNEL | Action on unjoined channel | `{"command":"442","to":"alice","params":["#general"],"body":["You're not on that channel"]}` | | `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"482","to":"alice","params":["#general"],"body":["You're not channel operator"]}` | -**Note:** Numeric replies are planned for full implementation. The current MVP -returns standard HTTP error responses (4xx/5xx with JSON error bodies) instead -of numeric replies for error conditions. Numeric replies in the message queue -will be added post-MVP. +**Note:** Numeric replies are now implemented. All IRC command responses +(success and error) are delivered as numeric replies through the message queue. +HTTP error codes are reserved for transport-level issues (auth failures, +malformed requests, server errors). The `params` field in the message envelope +carries IRC-style parameters (e.g., channel name, target nick). ### Channel Modes @@ -1054,8 +1055,8 @@ reference with all required and optional fields. | Command | Required Fields | Optional | Response Status | |-----------|---------------------|---------------|-----------------| -| `PRIVMSG` | `to`, `body` | `meta` | 201 Created | -| `NOTICE` | `to`, `body` | `meta` | 201 Created | +| `PRIVMSG` | `to`, `body` | `meta` | 200 OK | +| `NOTICE` | `to`, `body` | `meta` | 200 OK | | `JOIN` | `to` | | 200 OK | | `PART` | `to` | `body` | 200 OK | | `NICK` | `body` | | 200 OK | @@ -1063,18 +1064,44 @@ reference with all required and optional fields. | `QUIT` | | `body` | 200 OK | | `PING` | | | 200 OK | -**Errors (all commands):** +All IRC commands return HTTP 200 OK. IRC-level success and error responses +are delivered as **numeric replies** through the message queue (see +[Numeric Replies](#numeric-replies) below). HTTP error codes (4xx/5xx) are +reserved for transport-level problems: malformed JSON (400), missing/invalid +auth tokens (401), and server errors (500). + +**HTTP errors (transport-level only):** | Status | Error | When | |--------|-------|------| -| 400 | `invalid request` | Malformed JSON | -| 400 | `to field required` | Missing `to` for commands that need it | -| 400 | `body required` | Missing `body` for commands that need it | -| 400 | `unknown command: X` | Unrecognized command | +| 400 | `invalid request` | Malformed JSON or empty command | | 401 | `unauthorized` | Missing or invalid auth token | -| 404 | `channel not found` | Target channel doesn't exist | -| 404 | `user not found` | DM target nick doesn't exist | -| 409 | `nick already in use` | NICK target is taken | +| 500 | `internal error` | Server-side failure | + +**IRC numeric error replies (delivered via message queue):** + +| Numeric | Name | When | +|---------|------|------| +| 401 | ERR_NOSUCHNICK | DM target nick doesn't exist | +| 403 | ERR_NOSUCHCHANNEL | Target channel doesn't exist or invalid name | +| 421 | ERR_UNKNOWNCOMMAND | Unrecognized command | +| 432 | ERR_ERRONEUSNICKNAME | Invalid nickname format | +| 433 | ERR_NICKNAMEINUSE | NICK target is taken | +| 442 | ERR_NOTONCHANNEL | Not a member of the target channel | +| 461 | ERR_NEEDMOREPARAMS | Missing required fields (to, body) | + +**IRC numeric success replies (delivered via message queue):** + +| Numeric | Name | When | +|---------|------|------| +| 001 | RPL_WELCOME | Sent on session creation/login | +| 331 | RPL_NOTOPIC | Channel has no topic (on JOIN) | +| 332 | RPL_TOPIC | Channel topic (on JOIN, TOPIC set) | +| 353 | RPL_NAMREPLY | Channel member list (on JOIN) | +| 366 | RPL_ENDOFNAMES | End of NAMES list (on JOIN) | +| 375 | RPL_MOTDSTART | Start of MOTD | +| 372 | RPL_MOTD | MOTD line | +| 376 | RPL_ENDOFMOTD | End of MOTD | ### GET /api/v1/history — Message History diff --git a/internal/db/queries.go b/internal/db/queries.go index 5b209dd..193f639 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -35,6 +35,7 @@ type IRCMessage struct { Command string `json:"command"` From string `json:"from,omitempty"` To string `json:"to,omitempty"` + Params json.RawMessage `json:"params,omitempty"` Body json.RawMessage `json:"body,omitempty"` TS string `json:"ts"` Meta json.RawMessage `json:"meta,omitempty"` @@ -491,12 +492,17 @@ func (database *Database) GetSessionChannelIDs( func (database *Database) InsertMessage( ctx context.Context, command, from, target string, + params json.RawMessage, body json.RawMessage, meta json.RawMessage, ) (int64, string, error) { msgUUID := uuid.New().String() now := time.Now().UTC() + if params == nil { + params = json.RawMessage("[]") + } + if body == nil { body = json.RawMessage("[]") } @@ -508,10 +514,10 @@ func (database *Database) InsertMessage( res, err := database.conn.ExecContext(ctx, `INSERT INTO messages (uuid, command, msg_from, msg_to, - body, meta, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, + params, body, meta, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, msgUUID, command, from, target, - string(body), string(meta), now) + string(params), string(body), string(meta), now) if err != nil { return 0, "", fmt.Errorf( "insert message: %w", err, @@ -578,7 +584,7 @@ func (database *Database) PollMessages( rows, err := database.conn.QueryContext(ctx, `SELECT cq.id, m.uuid, m.command, m.msg_from, m.msg_to, - m.body, m.meta, m.created_at + m.params, m.body, m.meta, m.created_at FROM client_queues cq INNER JOIN messages m ON m.id = cq.message_id @@ -642,7 +648,7 @@ func (database *Database) queryHistory( if beforeID > 0 { rows, err := database.conn.QueryContext(ctx, `SELECT id, uuid, command, msg_from, - msg_to, body, meta, created_at + msg_to, params, body, meta, created_at FROM messages WHERE msg_to = ? AND id < ? AND command = 'PRIVMSG' @@ -659,7 +665,7 @@ func (database *Database) queryHistory( rows, err := database.conn.QueryContext(ctx, `SELECT id, uuid, command, msg_from, - msg_to, body, meta, created_at + msg_to, params, body, meta, created_at FROM messages WHERE msg_to = ? AND command = 'PRIVMSG' @@ -684,16 +690,16 @@ func scanMessages( for rows.Next() { var ( - msg IRCMessage - qID int64 - body, meta string - createdAt time.Time + msg IRCMessage + qID int64 + params, body, meta string + createdAt time.Time ) err := rows.Scan( &qID, &msg.ID, &msg.Command, &msg.From, &msg.To, - &body, &meta, &createdAt, + ¶ms, &body, &meta, &createdAt, ) if err != nil { return nil, fallbackQID, fmt.Errorf( @@ -701,6 +707,10 @@ func scanMessages( ) } + if params != "" && params != "[]" { + msg.Params = json.RawMessage(params) + } + msg.Body = json.RawMessage(body) msg.Meta = json.RawMessage(meta) msg.TS = createdAt.Format(time.RFC3339Nano) diff --git a/internal/db/queries_test.go b/internal/db/queries_test.go index f750baf..15814a2 100644 --- a/internal/db/queries_test.go +++ b/internal/db/queries_test.go @@ -383,7 +383,7 @@ func TestInsertMessage(t *testing.T) { body := json.RawMessage(`["hello"]`) dbID, msgUUID, err := database.InsertMessage( - ctx, "PRIVMSG", "poller", "#test", body, nil, + ctx, "PRIVMSG", "poller", "#test", nil, body, nil, ) if err != nil { t.Fatal(err) @@ -417,7 +417,7 @@ func TestPollMessages(t *testing.T) { body := json.RawMessage(`["hello"]`) dbID, _, err := database.InsertMessage( - ctx, "PRIVMSG", "poller", "#test", body, nil, + ctx, "PRIVMSG", "poller", "#test", nil, body, nil, ) if err != nil { t.Fatal(err) @@ -475,7 +475,7 @@ func TestGetHistory(t *testing.T) { for range msgCount { _, _, err := database.InsertMessage( ctx, "PRIVMSG", "user", "#hist", - json.RawMessage(`["msg"]`), nil, + nil, json.RawMessage(`["msg"]`), nil, ) if err != nil { t.Fatal(err) @@ -627,7 +627,7 @@ func TestEnqueueToClient(t *testing.T) { body := json.RawMessage(`["test"]`) dbID, _, err := database.InsertMessage( - ctx, "PRIVMSG", "sender", "#ch", body, nil, + ctx, "PRIVMSG", "sender", "#ch", nil, body, nil, ) if err != nil { t.Fatal(err) diff --git a/internal/db/schema/001_initial.sql b/internal/db/schema/001_initial.sql index 67ccfa6..68eb87c 100644 --- a/internal/db/schema/001_initial.sql +++ b/internal/db/schema/001_initial.sql @@ -50,6 +50,7 @@ CREATE TABLE IF NOT EXISTS messages ( command TEXT NOT NULL DEFAULT 'PRIVMSG', msg_from TEXT NOT NULL DEFAULT '', msg_to TEXT NOT NULL DEFAULT '', + params TEXT NOT NULL DEFAULT '[]', body TEXT NOT NULL DEFAULT '[]', meta TEXT NOT NULL DEFAULT '{}', created_at DATETIME DEFAULT CURRENT_TIMESTAMP diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 5ed1e18..092428a 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -91,7 +91,7 @@ func (hdlr *Handlers) fanOut( sessionIDs []int64, ) (string, error) { dbID, msgUUID, err := hdlr.params.Database.InsertMessage( - request.Context(), command, from, target, body, nil, + request.Context(), command, from, target, nil, body, nil, ) if err != nil { return "", fmt.Errorf("insert message: %w", err) @@ -185,7 +185,7 @@ func (hdlr *Handlers) handleCreateSession( return } - hdlr.deliverMOTD(request, clientID, sessionID) + hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick) hdlr.respondJSON(writer, request, map[string]any{ "id": sessionID, @@ -219,49 +219,76 @@ func (hdlr *Handlers) handleCreateSessionError( ) } +// deliverWelcome sends the RPL_WELCOME (001) numeric to a +// new client. +func (hdlr *Handlers) deliverWelcome( + request *http.Request, + clientID int64, + nick string, +) { + ctx := request.Context() + + hdlr.enqueueNumeric( + ctx, clientID, "001", nick, nil, + "Welcome to the network, "+nick, + ) +} + // deliverMOTD sends the MOTD as IRC numeric messages to a // new client. func (hdlr *Handlers) deliverMOTD( request *http.Request, clientID, sessionID int64, + nick string, ) { motd := hdlr.params.Config.MOTD - serverName := hdlr.params.Config.ServerName - - if serverName == "" { - serverName = "neoirc" - } - - if motd == "" { - return - } + srvName := hdlr.serverName() ctx := request.Context() + hdlr.deliverWelcome(request, clientID, nick) + + if motd == "" { + hdlr.broker.Notify(sessionID) + + return + } + hdlr.enqueueNumeric( - ctx, clientID, "375", serverName, - "- "+serverName+" Message of the Day -", + ctx, clientID, "375", nick, nil, + "- "+srvName+" Message of the Day -", ) for line := range strings.SplitSeq(motd, "\n") { hdlr.enqueueNumeric( - ctx, clientID, "372", serverName, + ctx, clientID, "372", nick, nil, "- "+line, ) } hdlr.enqueueNumeric( - ctx, clientID, "376", serverName, + ctx, clientID, "376", nick, nil, "End of /MOTD command.", ) hdlr.broker.Notify(sessionID) } +func (hdlr *Handlers) serverName() string { + name := hdlr.params.Config.ServerName + if name == "" { + return "neoirc" + } + + return name +} + func (hdlr *Handlers) enqueueNumeric( ctx context.Context, clientID int64, - command, serverName, text string, + command, nick string, + params []string, + text string, ) { body, err := json.Marshal([]string{text}) if err != nil { @@ -272,9 +299,22 @@ func (hdlr *Handlers) enqueueNumeric( return } + var paramsJSON json.RawMessage + + if len(params) > 0 { + paramsJSON, err = json.Marshal(params) + if err != nil { + hdlr.log.Error( + "marshal numeric params", "error", err, + ) + + return + } + } + dbID, _, insertErr := hdlr.params.Database.InsertMessage( - ctx, command, serverName, "", - json.RawMessage(body), nil, + ctx, command, hdlr.serverName(), nick, + paramsJSON, json.RawMessage(body), nil, ) if insertErr != nil { hdlr.log.Error( @@ -532,7 +572,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc { writer, request.Body, hdlr.maxBodySize(), ) - sessionID, _, nick, ok := + sessionID, clientID, nick, ok := hdlr.requireAuth(writer, request) if !ok { return @@ -582,7 +622,8 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc { } hdlr.dispatchCommand( - writer, request, sessionID, nick, + writer, request, + sessionID, clientID, nick, payload.Command, payload.To, payload.Body, bodyLines, ) @@ -592,7 +633,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc { func (hdlr *Handlers) dispatchCommand( writer http.ResponseWriter, request *http.Request, - sessionID int64, + sessionID, clientID int64, nick, command, target string, body json.RawMessage, bodyLines func() []string, @@ -600,24 +641,30 @@ func (hdlr *Handlers) dispatchCommand( switch command { case cmdPrivmsg, "NOTICE": hdlr.handlePrivmsg( - writer, request, sessionID, nick, + writer, request, + sessionID, clientID, nick, command, target, body, bodyLines, ) case "JOIN": hdlr.handleJoin( - writer, request, sessionID, nick, target, + writer, request, + sessionID, clientID, nick, target, ) case "PART": hdlr.handlePart( - writer, request, sessionID, nick, target, body, + writer, request, + sessionID, clientID, nick, target, body, ) case "NICK": hdlr.handleNick( - writer, request, sessionID, nick, bodyLines, + writer, request, + sessionID, clientID, nick, bodyLines, ) case "TOPIC": hdlr.handleTopic( - writer, request, nick, target, body, bodyLines, + writer, request, + sessionID, clientID, nick, + target, body, bodyLines, ) case "QUIT": hdlr.handleQuit( @@ -627,50 +674,63 @@ func (hdlr *Handlers) dispatchCommand( hdlr.respondJSON(writer, request, map[string]string{ "command": "PONG", - "from": hdlr.params.Config.ServerName, + "from": hdlr.serverName(), }, http.StatusOK) default: - hdlr.respondError( - writer, request, - "unknown command: "+command, - http.StatusBadRequest, + hdlr.enqueueNumeric( + request.Context(), clientID, + "421", nick, []string{command}, + "Unknown command", ) + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "error"}, + http.StatusOK) } } func (hdlr *Handlers) handlePrivmsg( writer http.ResponseWriter, request *http.Request, - sessionID int64, + sessionID, clientID int64, nick, command, target string, body json.RawMessage, bodyLines func() []string, ) { if target == "" { - hdlr.respondError( - writer, request, - "to field required", - http.StatusBadRequest, + hdlr.enqueueNumeric( + request.Context(), clientID, + "461", nick, []string{command}, + "Not enough parameters", ) + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "error"}, + http.StatusOK) return } lines := bodyLines() if len(lines) == 0 { - hdlr.respondError( - writer, request, - "body required", - http.StatusBadRequest, + hdlr.enqueueNumeric( + request.Context(), clientID, + "461", nick, []string{command}, + "Not enough parameters", ) + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "error"}, + http.StatusOK) return } if strings.HasPrefix(target, "#") { hdlr.handleChannelMsg( - writer, request, sessionID, nick, + writer, request, + sessionID, clientID, nick, command, target, body, ) @@ -678,15 +738,36 @@ func (hdlr *Handlers) handlePrivmsg( } hdlr.handleDirectMsg( - writer, request, sessionID, nick, + writer, request, + sessionID, clientID, nick, command, target, body, ) } +// respondIRCError enqueues a numeric error reply, notifies +// the broker, and sends HTTP 200 with {"status":"error"}. +func (hdlr *Handlers) respondIRCError( + writer http.ResponseWriter, + request *http.Request, + clientID, sessionID int64, + numeric, nick string, + params []string, + text string, +) { + hdlr.enqueueNumeric( + request.Context(), clientID, + numeric, nick, params, text, + ) + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "error"}, + http.StatusOK) +} + func (hdlr *Handlers) handleChannelMsg( writer http.ResponseWriter, request *http.Request, - sessionID int64, + sessionID, clientID int64, nick, command, target string, body json.RawMessage, ) { @@ -694,10 +775,10 @@ func (hdlr *Handlers) handleChannelMsg( request.Context(), target, ) if err != nil { - hdlr.respondError( - writer, request, - "channel not found", - http.StatusNotFound, + hdlr.respondIRCError( + writer, request, clientID, sessionID, + "403", nick, []string{target}, + "No such channel", ) return @@ -720,15 +801,27 @@ func (hdlr *Handlers) handleChannelMsg( } if !isMember { - hdlr.respondError( - writer, request, - "not a member of this channel", - http.StatusForbidden, + hdlr.respondIRCError( + writer, request, clientID, sessionID, + "442", nick, []string{target}, + "You're not on that channel", ) return } + hdlr.sendChannelMsg( + writer, request, command, nick, target, body, chID, + ) +} + +func (hdlr *Handlers) sendChannelMsg( + writer http.ResponseWriter, + request *http.Request, + command, nick, target string, + body json.RawMessage, + chID int64, +) { memberIDs, err := hdlr.params.Database.GetChannelMemberIDs( request.Context(), chID, ) @@ -761,13 +854,13 @@ func (hdlr *Handlers) handleChannelMsg( hdlr.respondJSON(writer, request, map[string]string{"id": msgUUID, "status": "sent"}, - http.StatusCreated) + http.StatusOK) } func (hdlr *Handlers) handleDirectMsg( writer http.ResponseWriter, request *http.Request, - sessionID int64, + sessionID, clientID int64, nick, command, target string, body json.RawMessage, ) { @@ -775,11 +868,15 @@ func (hdlr *Handlers) handleDirectMsg( request.Context(), target, ) if err != nil { - hdlr.respondError( - writer, request, - "user not found", - http.StatusNotFound, + hdlr.enqueueNumeric( + request.Context(), clientID, + "401", nick, []string{target}, + "No such nick/channel", ) + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "error"}, + http.StatusOK) return } @@ -805,20 +902,20 @@ func (hdlr *Handlers) handleDirectMsg( hdlr.respondJSON(writer, request, map[string]string{"id": msgUUID, "status": "sent"}, - http.StatusCreated) + http.StatusOK) } func (hdlr *Handlers) handleJoin( writer http.ResponseWriter, request *http.Request, - sessionID int64, + sessionID, clientID int64, nick, target string, ) { if target == "" { - hdlr.respondError( - writer, request, - "to field required", - http.StatusBadRequest, + hdlr.respondIRCError( + writer, request, clientID, sessionID, + "461", nick, []string{"JOIN"}, + "Not enough parameters", ) return @@ -830,15 +927,27 @@ func (hdlr *Handlers) handleJoin( } if !validChannelRe.MatchString(channel) { - hdlr.respondError( - writer, request, - "invalid channel name", - http.StatusBadRequest, + hdlr.respondIRCError( + writer, request, clientID, sessionID, + "403", nick, []string{channel}, + "No such channel", ) return } + hdlr.executeJoin( + writer, request, + sessionID, clientID, nick, channel, + ) +} + +func (hdlr *Handlers) executeJoin( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick, channel string, +) { chID, err := hdlr.params.Database.GetOrCreateChannel( request.Context(), channel, ) @@ -879,6 +988,10 @@ func (hdlr *Handlers) handleJoin( request, "JOIN", nick, channel, nil, memberIDs, ) + hdlr.deliverJoinNumerics( + request, clientID, sessionID, nick, channel, chID, + ) + hdlr.respondJSON(writer, request, map[string]string{ "status": "joined", @@ -887,19 +1000,96 @@ func (hdlr *Handlers) handleJoin( http.StatusOK) } +// deliverJoinNumerics sends RPL_TOPIC/RPL_NOTOPIC, +// RPL_NAMREPLY, and RPL_ENDOFNAMES to the joining client. +func (hdlr *Handlers) deliverJoinNumerics( + request *http.Request, + clientID, sessionID int64, + nick, channel string, + chID int64, +) { + ctx := request.Context() + + chInfo, err := hdlr.params.Database.GetChannelByName( + ctx, channel, + ) + if err == nil { + _ = chInfo // chInfo is the ID; topic comes from DB. + } + + // Get topic from channel info. + channels, listErr := hdlr.params.Database.ListChannels( + ctx, sessionID, + ) + + topic := "" + + if listErr == nil { + for _, ch := range channels { + if ch.Name == channel { + topic = ch.Topic + + break + } + } + } + + if topic != "" { + hdlr.enqueueNumeric( + ctx, clientID, "332", nick, + []string{channel}, topic, + ) + } else { + hdlr.enqueueNumeric( + ctx, clientID, "331", nick, + []string{channel}, "No topic is set", + ) + } + + // Get member list for NAMES reply. + members, memErr := hdlr.params.Database.ChannelMembers( + ctx, chID, + ) + + if memErr == nil && len(members) > 0 { + nicks := make([]string, 0, len(members)) + + for _, mem := range members { + nicks = append(nicks, mem.Nick) + } + + hdlr.enqueueNumeric( + ctx, clientID, "353", nick, + []string{"=", channel}, + strings.Join(nicks, " "), + ) + } + + hdlr.enqueueNumeric( + ctx, clientID, "366", nick, + []string{channel}, "End of /NAMES list", + ) + + hdlr.broker.Notify(sessionID) +} + func (hdlr *Handlers) handlePart( writer http.ResponseWriter, request *http.Request, - sessionID int64, + sessionID, clientID int64, nick, target string, body json.RawMessage, ) { if target == "" { - hdlr.respondError( - writer, request, - "to field required", - http.StatusBadRequest, + hdlr.enqueueNumeric( + request.Context(), clientID, + "461", nick, []string{"PART"}, + "Not enough parameters", ) + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "error"}, + http.StatusOK) return } @@ -913,11 +1103,15 @@ func (hdlr *Handlers) handlePart( request.Context(), channel, ) if err != nil { - hdlr.respondError( - writer, request, - "channel not found", - http.StatusNotFound, + hdlr.enqueueNumeric( + request.Context(), clientID, + "403", nick, []string{channel}, + "No such channel", ) + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "error"}, + http.StatusOK) return } @@ -961,16 +1155,16 @@ func (hdlr *Handlers) handlePart( func (hdlr *Handlers) handleNick( writer http.ResponseWriter, request *http.Request, - sessionID int64, + sessionID, clientID int64, nick string, bodyLines func() []string, ) { lines := bodyLines() if len(lines) == 0 { - hdlr.respondError( - writer, request, - "body required (new nick)", - http.StatusBadRequest, + hdlr.respondIRCError( + writer, request, clientID, sessionID, + "461", nick, []string{"NICK"}, + "Not enough parameters", ) return @@ -979,10 +1173,10 @@ func (hdlr *Handlers) handleNick( newNick := strings.TrimSpace(lines[0]) if !validNickRe.MatchString(newNick) { - hdlr.respondError( - writer, request, - "invalid nick", - http.StatusBadRequest, + hdlr.respondIRCError( + writer, request, clientID, sessionID, + "432", nick, []string{newNick}, + "Erroneous nickname", ) return @@ -998,15 +1192,27 @@ func (hdlr *Handlers) handleNick( return } + hdlr.executeNickChange( + writer, request, + sessionID, clientID, nick, newNick, + ) +} + +func (hdlr *Handlers) executeNickChange( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick, newNick string, +) { err := hdlr.params.Database.ChangeNick( request.Context(), sessionID, newNick, ) if err != nil { if strings.Contains(err.Error(), "UNIQUE") { - hdlr.respondError( - writer, request, - "nick already in use", - http.StatusConflict, + hdlr.respondIRCError( + writer, request, clientID, sessionID, + "433", nick, []string{newNick}, + "Nickname is already in use", ) return @@ -1056,7 +1262,7 @@ func (hdlr *Handlers) broadcastNick( dbID, _, _ := hdlr.params.Database.InsertMessage( request.Context(), "NICK", oldNick, "", - json.RawMessage(nickBody), nil, + nil, json.RawMessage(nickBody), nil, ) _ = hdlr.params.Database.EnqueueToSession( @@ -1088,15 +1294,16 @@ func (hdlr *Handlers) broadcastNick( func (hdlr *Handlers) handleTopic( writer http.ResponseWriter, request *http.Request, + sessionID, clientID int64, nick, target string, body json.RawMessage, bodyLines func() []string, ) { if target == "" { - hdlr.respondError( - writer, request, - "to field required", - http.StatusBadRequest, + hdlr.respondIRCError( + writer, request, clientID, sessionID, + "461", nick, []string{"TOPIC"}, + "Not enough parameters", ) return @@ -1104,46 +1311,60 @@ func (hdlr *Handlers) handleTopic( lines := bodyLines() if len(lines) == 0 { - hdlr.respondError( - writer, request, - "body required (topic text)", - http.StatusBadRequest, + hdlr.respondIRCError( + writer, request, clientID, sessionID, + "461", nick, []string{"TOPIC"}, + "Not enough parameters", ) return } - topic := strings.Join(lines, " ") - channel := target if !strings.HasPrefix(channel, "#") { channel = "#" + channel } - err := hdlr.params.Database.SetTopic( - request.Context(), channel, topic, + chID, err := hdlr.params.Database.GetChannelByName( + request.Context(), channel, ) if err != nil { - hdlr.log.Error( - "set topic failed", "error", err, - ) - hdlr.respondError( - writer, request, - "internal error", - http.StatusInternalServerError, + hdlr.respondIRCError( + writer, request, clientID, sessionID, + "403", nick, []string{channel}, + "No such channel", ) return } - chID, err := hdlr.params.Database.GetChannelByName( - request.Context(), channel, + hdlr.executeTopic( + writer, request, + sessionID, clientID, nick, + channel, strings.Join(lines, " "), + body, chID, ) - if err != nil { +} + +func (hdlr *Handlers) executeTopic( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick, channel, topic string, + body json.RawMessage, + chID int64, +) { + setErr := hdlr.params.Database.SetTopic( + request.Context(), channel, topic, + ) + if setErr != nil { + hdlr.log.Error( + "set topic failed", "error", setErr, + ) hdlr.respondError( writer, request, - "channel not found", - http.StatusNotFound, + "internal error", + http.StatusInternalServerError, ) return @@ -1157,6 +1378,12 @@ func (hdlr *Handlers) handleTopic( request, "TOPIC", nick, channel, body, memberIDs, ) + hdlr.enqueueNumeric( + request.Context(), clientID, + "332", nick, []string{channel}, topic, + ) + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, map[string]string{ "status": "ok", "topic": topic, @@ -1182,7 +1409,8 @@ func (hdlr *Handlers) handleQuit( if len(channels) > 0 { dbID, _, _ = hdlr.params.Database.InsertMessage( - request.Context(), "QUIT", nick, "", body, nil, + request.Context(), "QUIT", nick, "", + nil, body, nil, ) } @@ -1431,7 +1659,8 @@ func (hdlr *Handlers) cleanupUser( if len(channels) > 0 { quitDBID, _, _ = hdlr.params.Database.InsertMessage( - ctx, "QUIT", nick, "", nil, nil, + ctx, "QUIT", nick, "", + nil, nil, nil, ) } diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index adfc77e..b19587b 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -462,6 +462,19 @@ func findMessage( return false } +func findNumeric( + msgs []map[string]any, + numeric string, +) bool { + for _, msg := range msgs { + if msg[commandKey] == numeric { + return true + } + } + + return false +} + // --- Tests --- func TestCreateSessionValid(t *testing.T) { @@ -473,6 +486,47 @@ func TestCreateSessionValid(t *testing.T) { } } +func TestWelcomeNumeric(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("welcomer") + + msgs, _ := tserver.pollMessages(token, 0) + + if !findNumeric(msgs, "001") { + t.Fatalf( + "expected RPL_WELCOME (001), got %v", + msgs, + ) + } +} + +func TestJoinNumerics(t *testing.T) { + tserver := newTestServer(t) + token := tserver.createSession("jnumtest") + + _, lastID := tserver.pollMessages(token, 0) + + tserver.sendCommand(token, map[string]any{ + commandKey: joinCmd, toKey: "#numtest", + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + if !findNumeric(msgs, "353") { + t.Fatalf( + "expected RPL_NAMREPLY (353), got %v", + msgs, + ) + } + + if !findNumeric(msgs, "366") { + t.Fatalf( + "expected RPL_ENDOFNAMES (366), got %v", + msgs, + ) + } +} + func TestCreateSessionDuplicate(t *testing.T) { tserver := newTestServer(t) tserver.createSession("alice") @@ -668,11 +722,23 @@ func TestJoinMissingTo(t *testing.T) { tserver := newTestServer(t) token := tserver.createSession("joiner3") + // Drain initial MOTD/welcome numerics. + _, lastID := tserver.pollMessages(token, 0) + status, _ := tserver.sendCommand( token, map[string]any{commandKey: joinCmd}, ) - if status != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", status) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + msgs, _ := tserver.pollMessages(token, lastID) + + if !findNumeric(msgs, "461") { + t.Fatalf( + "expected ERR_NEEDMOREPARAMS (461), got %v", + msgs, + ) } } @@ -699,9 +765,9 @@ func TestChannelMessage(t *testing.T) { bodyKey: []string{"hello world"}, }, ) - if status != http.StatusCreated { + if status != http.StatusOK { t.Fatalf( - "expected 201, got %d: %v", status, result, + "expected 200, got %d: %v", status, result, ) } @@ -728,11 +794,22 @@ func TestMessageMissingBody(t *testing.T) { commandKey: joinCmd, toKey: "#test", }) + _, lastID := tserver.pollMessages(token, 0) + status, _ := tserver.sendCommand(token, map[string]any{ commandKey: privmsgCmd, toKey: "#test", }) - if status != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", status) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + msgs, _ := tserver.pollMessages(token, lastID) + + if !findNumeric(msgs, "461") { + t.Fatalf( + "expected ERR_NEEDMOREPARAMS (461), got %v", + msgs, + ) } } @@ -740,12 +817,23 @@ func TestMessageMissingTo(t *testing.T) { tserver := newTestServer(t) token := tserver.createSession("noto") + _, lastID := tserver.pollMessages(token, 0) + status, _ := tserver.sendCommand(token, map[string]any{ commandKey: privmsgCmd, bodyKey: []string{"hello"}, }) - if status != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", status) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + msgs, _ := tserver.pollMessages(token, lastID) + + if !findNumeric(msgs, "461") { + t.Fatalf( + "expected ERR_NEEDMOREPARAMS (461), got %v", + msgs, + ) } } @@ -759,6 +847,8 @@ func TestNonMemberCannotSend(t *testing.T) { commandKey: joinCmd, toKey: "#private", }) + _, lastID := tserver.pollMessages(aliceToken, 0) + // Alice tries to send without joining. status, _ := tserver.sendCommand( aliceToken, @@ -768,8 +858,17 @@ func TestNonMemberCannotSend(t *testing.T) { bodyKey: []string{"sneaky"}, }, ) - if status != http.StatusForbidden { - t.Fatalf("expected 403, got %d", status) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + msgs, _ := tserver.pollMessages(aliceToken, lastID) + + if !findNumeric(msgs, "442") { + t.Fatalf( + "expected ERR_NOTONCHANNEL (442), got %v", + msgs, + ) } } @@ -786,9 +885,9 @@ func TestDirectMessage(t *testing.T) { bodyKey: []string{"hey bob"}, }, ) - if status != http.StatusCreated { + if status != http.StatusOK { t.Fatalf( - "expected 201, got %d: %v", status, result, + "expected 200, got %d: %v", status, result, ) } @@ -818,13 +917,24 @@ func TestDMToNonexistentUser(t *testing.T) { tserver := newTestServer(t) token := tserver.createSession("dmsender") + _, lastID := tserver.pollMessages(token, 0) + status, _ := tserver.sendCommand(token, map[string]any{ commandKey: privmsgCmd, toKey: "nobody", bodyKey: []string{"hello?"}, }) - if status != http.StatusNotFound { - t.Fatalf("expected 404, got %d", status) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + msgs, _ := tserver.pollMessages(token, lastID) + + if !findNumeric(msgs, "401") { + t.Fatalf( + "expected ERR_NOSUCHNICK (401), got %v", + msgs, + ) } } @@ -871,12 +981,23 @@ func TestNickCollision(t *testing.T) { tserver.createSession("taken_nick") + _, lastID := tserver.pollMessages(token, 0) + status, _ := tserver.sendCommand(token, map[string]any{ commandKey: "NICK", bodyKey: []string{"taken_nick"}, }) - if status != http.StatusConflict { - t.Fatalf("expected 409, got %d", status) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + msgs, _ := tserver.pollMessages(token, lastID) + + if !findNumeric(msgs, "433") { + t.Fatalf( + "expected ERR_NICKNAMEINUSE (433), got %v", + msgs, + ) } } @@ -884,12 +1005,23 @@ func TestNickInvalid(t *testing.T) { tserver := newTestServer(t) token := tserver.createSession("nickval") + _, lastID := tserver.pollMessages(token, 0) + status, _ := tserver.sendCommand(token, map[string]any{ commandKey: "NICK", bodyKey: []string{"bad nick!"}, }) - if status != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", status) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + msgs, _ := tserver.pollMessages(token, lastID) + + if !findNumeric(msgs, "432") { + t.Fatalf( + "expected ERR_ERRONEUSNICKNAME (432), got %v", + msgs, + ) } } @@ -897,11 +1029,22 @@ func TestNickEmptyBody(t *testing.T) { tserver := newTestServer(t) token := tserver.createSession("nicknobody") + _, lastID := tserver.pollMessages(token, 0) + status, _ := tserver.sendCommand( token, map[string]any{commandKey: "NICK"}, ) - if status != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", status) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + msgs, _ := tserver.pollMessages(token, lastID) + + if !findNumeric(msgs, "461") { + t.Fatalf( + "expected ERR_NEEDMOREPARAMS (461), got %v", + msgs, + ) } } @@ -938,12 +1081,23 @@ func TestTopicMissingTo(t *testing.T) { tserver := newTestServer(t) token := tserver.createSession("topicnoto") + _, lastID := tserver.pollMessages(token, 0) + status, _ := tserver.sendCommand(token, map[string]any{ commandKey: "TOPIC", bodyKey: []string{"topic"}, }) - if status != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", status) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + msgs, _ := tserver.pollMessages(token, lastID) + + if !findNumeric(msgs, "461") { + t.Fatalf( + "expected ERR_NEEDMOREPARAMS (461), got %v", + msgs, + ) } } @@ -955,11 +1109,22 @@ func TestTopicMissingBody(t *testing.T) { commandKey: joinCmd, toKey: "#topictest", }) + _, lastID := tserver.pollMessages(token, 0) + status, _ := tserver.sendCommand(token, map[string]any{ commandKey: "TOPIC", toKey: "#topictest", }) - if status != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", status) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + msgs, _ := tserver.pollMessages(token, lastID) + + if !findNumeric(msgs, "461") { + t.Fatalf( + "expected ERR_NEEDMOREPARAMS (461), got %v", + msgs, + ) } } @@ -1027,11 +1192,22 @@ func TestUnknownCommand(t *testing.T) { tserver := newTestServer(t) token := tserver.createSession("cmdtest") + _, lastID := tserver.pollMessages(token, 0) + status, _ := tserver.sendCommand( token, map[string]any{commandKey: "BOGUS"}, ) - if status != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", status) + if status != http.StatusOK { + t.Fatalf("expected 200, got %d", status) + } + + msgs, _ := tserver.pollMessages(token, lastID) + + if !findNumeric(msgs, "421") { + t.Fatalf( + "expected ERR_UNKNOWNCOMMAND (421), got %v", + msgs, + ) } } @@ -1278,12 +1454,18 @@ func TestLongPollTimeout(t *testing.T) { tserver := newTestServer(t) token := tserver.createSession("lp_timeout") + // Drain initial welcome/MOTD numerics. + _, lastID := tserver.pollMessages(token, 0) + start := time.Now() resp, err := doRequestAuth( t, http.MethodGet, - tserver.url(apiMessages+"?timeout=1"), + tserver.url(fmt.Sprintf( + "%s?timeout=1&after=%d", + apiMessages, lastID, + )), token, nil, ) diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index bc44866..af4010c 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -80,7 +80,7 @@ func (hdlr *Handlers) handleRegister( return } - hdlr.deliverMOTD(request, clientID, sessionID) + hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick) hdlr.respondJSON(writer, request, map[string]any{ "id": sessionID, @@ -162,7 +162,7 @@ func (hdlr *Handlers) handleLogin( return } - sessionID, _, token, err := + sessionID, clientID, token, err := hdlr.params.Database.LoginUser( request.Context(), payload.Nick, @@ -178,6 +178,10 @@ func (hdlr *Handlers) handleLogin( return } + hdlr.deliverMOTD( + request, clientID, sessionID, payload.Nick, + ) + hdlr.respondJSON(writer, request, map[string]any{ "id": sessionID, "nick": payload.Nick,