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,