From 8d26df60db7360a1119219923da7e171bb9b27fc Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 9 Mar 2026 14:53:49 -0700 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20implement=20IRC=20numerics=20batch?= =?UTF-8?q?=202=20=E2=80=94=20connection=20registration,=20channel=20ops,?= =?UTF-8?q?=20user=20queries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive IRC numeric reply support: Connection registration (001-005): - 002 RPL_YOURHOST, 003 RPL_CREATED, 004 RPL_MYINFO, 005 RPL_ISUPPORT - All sent automatically during session creation after RPL_WELCOME Server statistics (251-255): - RPL_LUSERCLIENT, RPL_LUSEROP, RPL_LUSERCHANNELS, RPL_LUSERME - Sent during connection registration and via LUSERS command Channel operations: - MODE command: query channel modes (324 RPL_CHANNELMODEIS, 329 RPL_CREATIONTIME) - MODE command: query user modes (221 RPL_UMODEIS) - NAMES command: query channel member list (353/366) - LIST command: list all channels (322 RPL_LIST, 323 end of list) User queries: - WHOIS command: 311/312/318/319 numerics - WHO command: 352 RPL_WHOREPLY, 315 RPL_ENDOFWHO Database additions: - GetChannelCount, ListAllChannelsWithCounts - GetChannelCreatedAt, GetSessionCreatedAt Also adds StartTime to Globals for RPL_CREATED and updates README with comprehensive documentation of all new commands and numerics. closes #52 --- README.md | 156 ++++++++- internal/db/queries.go | 122 +++++++ internal/globals/globals.go | 16 +- internal/handlers/api.go | 582 +++++++++++++++++++++++++++++++++- internal/handlers/api_test.go | 5 +- 5 files changed, 857 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 9a7905d..e2c5e72 100644 --- a/README.md +++ b/README.md @@ -764,21 +764,98 @@ not pollute the message queue. **IRC reference:** RFC 1459 §4.6.2, §4.6.3 -#### MODE — Set/Query Modes (Planned) +#### MODE — Query Modes -Set channel or user modes. +Query channel or user modes. Returns the current mode string and, for +channels, the creation timestamp. **C2S:** ```json -{"command": "MODE", "to": "#general", "params": ["+m"]} -{"command": "MODE", "to": "#general", "params": ["+o", "alice"]} +{"command": "MODE", "to": "#general"} +{"command": "MODE", "to": "alice"} ``` -**Status:** Not yet implemented. See [Channel Modes](#channel-modes) for the -planned mode set. +**S2C (via message queue):** + +For channels, the server sends RPL_CHANNELMODEIS (324) and +RPL_CREATIONTIME (329): +```json +{"command": "324", "to": "alice", "params": ["#general", "+n"]} +{"command": "329", "to": "alice", "params": ["#general", "1709251200"]} +``` + +For users, the server sends RPL_UMODEIS (221): +```json +{"command": "221", "to": "alice", "body": ["+"]} +``` + +**Note:** Mode changes (setting/unsetting modes) are not yet implemented. +Currently only query is supported. **IRC reference:** RFC 1459 §4.2.3 +#### NAMES — Channel Member List + +Request the member list for a channel. Returns RPL_NAMREPLY (353) and +RPL_ENDOFNAMES (366). + +**C2S:** +```json +{"command": "NAMES", "to": "#general"} +``` + +**IRC reference:** RFC 1459 §4.2.5 + +#### LIST — List Channels + +Request a list of all channels with member counts. Returns RPL_LIST (322) +for each channel followed by RPL_LISTEND (323). + +**C2S:** +```json +{"command": "LIST"} +``` + +**IRC reference:** RFC 1459 §4.2.6 + +#### WHOIS — User Information + +Query information about a user. Returns RPL_WHOISUSER (311), +RPL_WHOISSERVER (312), RPL_WHOISCHANNELS (319), and RPL_ENDOFWHOIS (318). + +**C2S:** +```json +{"command": "WHOIS", "to": "alice"} +``` + +**IRC reference:** RFC 1459 §4.5.2 + +#### WHO — Channel User List + +Query users in a channel. Returns RPL_WHOREPLY (352) for each user followed +by RPL_ENDOFWHO (315). + +**C2S:** +```json +{"command": "WHO", "to": "#general"} +``` + +**IRC reference:** RFC 1459 §4.5.1 + +#### LUSERS — Server Statistics + +Request server user/channel statistics. Returns RPL_LUSERCLIENT (251), +RPL_LUSEROP (252), RPL_LUSERCHANNELS (254), and RPL_LUSERME (255). + +**C2S:** +```json +{"command": "LUSERS"} +``` + +LUSERS replies are also sent automatically during connection registration. + +**IRC reference:** RFC 1459 §4.3.2 + #### KICK — Kick User (Planned) Remove a user from a channel. @@ -828,12 +905,27 @@ the server to the client (never C2S) and use 3-digit string codes in the | Code | Name | When Sent | Example | |------|----------------------|-----------|---------| | `001` | RPL_WELCOME | After session creation | `{"command":"001","to":"alice","body":["Welcome to the network, alice"]}` | -| `002` | RPL_YOURHOST | After session creation | `{"command":"002","to":"alice","body":["Your host is neoirc-server, running version 0.1"]}` | +| `002` | RPL_YOURHOST | After session creation | `{"command":"002","to":"alice","body":["Your host is neoirc, running version 0.1"]}` | | `003` | RPL_CREATED | After session creation | `{"command":"003","to":"alice","body":["This server was created 2026-02-10"]}` | -| `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["neoirc-server","0.1","","imnst"]}` | +| `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["neoirc","0.1","","imnst"]}` | +| `005` | RPL_ISUPPORT | After session creation | `{"command":"005","to":"alice","params":["CHANTYPES=#","NICKLEN=32","NETWORK=neoirc"],"body":["are supported by this server"]}` | +| `221` | RPL_UMODEIS | In response to user MODE query | `{"command":"221","to":"alice","body":["+"]}` | +| `251` | RPL_LUSERCLIENT | On connect or LUSERS command | `{"command":"251","to":"alice","body":["There are 5 users and 0 invisible on 1 servers"]}` | +| `252` | RPL_LUSEROP | On connect or LUSERS command | `{"command":"252","to":"alice","params":["0"],"body":["operator(s) online"]}` | +| `254` | RPL_LUSERCHANNELS | On connect or LUSERS command | `{"command":"254","to":"alice","params":["3"],"body":["channels formed"]}` | +| `255` | RPL_LUSERME | On connect or LUSERS command | `{"command":"255","to":"alice","body":["I have 5 clients and 1 servers"]}` | +| `311` | RPL_WHOISUSER | In response to WHOIS | `{"command":"311","to":"alice","params":["bob","bob","neoirc","*"],"body":["bob"]}` | +| `312` | RPL_WHOISSERVER | In response to WHOIS | `{"command":"312","to":"alice","params":["bob","neoirc"],"body":["neoirc server"]}` | +| `315` | RPL_ENDOFWHO | End of WHO response | `{"command":"315","to":"alice","params":["#general"],"body":["End of /WHO list"]}` | +| `318` | RPL_ENDOFWHOIS | End of WHOIS response | `{"command":"318","to":"alice","params":["bob"],"body":["End of /WHOIS list"]}` | +| `319` | RPL_WHOISCHANNELS | In response to WHOIS | `{"command":"319","to":"alice","params":["bob"],"body":["#general #dev"]}` | | `322` | RPL_LIST | In response to LIST | `{"command":"322","to":"alice","params":["#general","5"],"body":["General discussion"]}` | | `323` | RPL_LISTEND | End of LIST response | `{"command":"323","to":"alice","body":["End of /LIST"]}` | +| `324` | RPL_CHANNELMODEIS | In response to channel MODE query | `{"command":"324","to":"alice","params":["#general","+n"]}` | +| `329` | RPL_CREATIONTIME | After channel MODE query | `{"command":"329","to":"alice","params":["#general","1709251200"]}` | +| `331` | RPL_NOTOPIC | Channel has no topic (on JOIN) | `{"command":"331","to":"alice","params":["#general"],"body":["No topic is set"]}` | | `332` | RPL_TOPIC | On JOIN or TOPIC query | `{"command":"332","to":"alice","params":["#general"],"body":["Welcome!"]}` | +| `352` | RPL_WHOREPLY | In response to WHO | `{"command":"352","to":"alice","params":["#general","bob","neoirc","neoirc","bob","H"],"body":["0 bob"]}` | | `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"353","to":"alice","params":["=","#general"],"body":["@op1 alice bob +voiced1"]}` | | `366` | RPL_ENDOFNAMES | End of NAMES response | `{"command":"366","to":"alice","params":["#general"],"body":["End of /NAMES list"]}` | | `372` | RPL_MOTD | MOTD line | `{"command":"372","to":"alice","body":["Welcome to the server"]}` | @@ -841,8 +933,11 @@ the server to the client (never C2S) and use 3-digit string codes in the | `376` | RPL_ENDOFMOTD | End of MOTD | `{"command":"376","to":"alice","body":["End of /MOTD command"]}` | | `401` | ERR_NOSUCHNICK | DM to nonexistent nick | `{"command":"401","to":"alice","params":["bob"],"body":["No such nick/channel"]}` | | `403` | ERR_NOSUCHCHANNEL | Action on nonexistent channel | `{"command":"403","to":"alice","params":["#nope"],"body":["No such channel"]}` | +| `421` | ERR_UNKNOWNCOMMAND | Unrecognized command | `{"command":"421","to":"alice","params":["FOO"],"body":["Unknown command"]}` | +| `432` | ERR_ERRONEUSNICKNAME | Invalid nick format | `{"command":"432","to":"alice","params":["bad nick!"],"body":["Erroneous nickname"]}` | | `433` | ERR_NICKNAMEINUSE | NICK to taken nick | `{"command":"433","to":"*","params":["alice"],"body":["Nickname is already in use"]}` | | `442` | ERR_NOTONCHANNEL | Action on unjoined channel | `{"command":"442","to":"alice","params":["#general"],"body":["You're not on that channel"]}` | +| `461` | ERR_NEEDMOREPARAMS | Missing required fields | `{"command":"461","to":"alice","params":["JOIN"],"body":["Not enough parameters"]}` | | `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"482","to":"alice","params":["#general"],"body":["You're not channel operator"]}` | **Note:** Numeric replies are now implemented. All IRC command responses @@ -1061,6 +1156,12 @@ reference with all required and optional fields. | `PART` | `to` | `body` | 200 OK | | `NICK` | `body` | | 200 OK | | `TOPIC` | `to`, `body` | | 200 OK | +| `MODE` | `to` | | 200 OK | +| `NAMES` | `to` | | 200 OK | +| `LIST` | | | 200 OK | +| `WHOIS` | `to` or `body` | | 200 OK | +| `WHO` | `to` | | 200 OK | +| `LUSERS` | | | 200 OK | | `QUIT` | | `body` | 200 OK | | `PING` | | | 200 OK | @@ -1095,10 +1196,29 @@ auth tokens (401), and server errors (500). | Numeric | Name | When | |---------|------|------| | 001 | RPL_WELCOME | Sent on session creation/login | +| 002 | RPL_YOURHOST | Sent on session creation/login | +| 003 | RPL_CREATED | Sent on session creation/login | +| 004 | RPL_MYINFO | Sent on session creation/login | +| 005 | RPL_ISUPPORT | Sent on session creation/login | +| 221 | RPL_UMODEIS | In response to user MODE query | +| 251 | RPL_LUSERCLIENT | On connect or LUSERS command | +| 252 | RPL_LUSEROP | On connect or LUSERS command | +| 254 | RPL_LUSERCHANNELS | On connect or LUSERS command | +| 255 | RPL_LUSERME | On connect or LUSERS command | +| 311 | RPL_WHOISUSER | WHOIS user info | +| 312 | RPL_WHOISSERVER | WHOIS server info | +| 315 | RPL_ENDOFWHO | End of WHO list | +| 318 | RPL_ENDOFWHOIS | End of WHOIS list | +| 319 | RPL_WHOISCHANNELS | WHOIS channels list | +| 322 | RPL_LIST | Channel in LIST response | +| 323 | RPL_LISTEND | End of LIST | +| 324 | RPL_CHANNELMODEIS | Channel mode query response | +| 329 | RPL_CREATIONTIME | Channel creation timestamp | | 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) | +| 352 | RPL_WHOREPLY | User in WHO response | +| 353 | RPL_NAMREPLY | Channel member list (on JOIN, NAMES) | +| 366 | RPL_ENDOFNAMES | End of NAMES list | | 375 | RPL_MOTDSTART | Start of MOTD | | 372 | RPL_MOTD | MOTD line | | 376 | RPL_ENDOFMOTD | End of MOTD | @@ -2104,10 +2224,18 @@ GET /api/v1/challenge - [ ] **Message rotation** — enforce `MAX_HISTORY` per channel - [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n` - [ ] **User channel modes** — `+o` (operator), `+v` (voice) -- [ ] **MODE command** — set/query channel and user modes +- [x] **MODE command** — query channel and user modes (set not yet implemented) +- [x] **NAMES command** — query channel member list +- [x] **LIST command** — list all channels with member counts +- [x] **WHOIS command** — query user information and channel membership +- [x] **WHO command** — query channel user list +- [x] **LUSERS command** — query server statistics +- [x] **Connection registration numerics** — 001-005 sent on session creation +- [x] **LUSERS numerics** — 251/252/254/255 sent on connect and via /LUSERS - [ ] **KICK command** — remove users from channels -- [ ] **Numeric replies** — send IRC numeric codes via the message queue - (001 welcome, 353 NAMES, 332 TOPIC, etc.) +- [x] **Numeric replies** — send IRC numeric codes via the message queue + (001-005 welcome, 251-255 LUSERS, 311-319 WHOIS, 322-329 LIST/MODE, + 331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-461 errors) - [ ] **Max message size enforcement** — reject oversized messages - [ ] **NOTICE command** — distinct from PRIVMSG (no auto-reply flag) - [ ] **Multi-client sessions** — add client to existing session @@ -2127,7 +2255,7 @@ GET /api/v1/challenge - [ ] **Push notifications** — optional webhook/push for mobile clients when messages arrive during disconnect - [ ] **Message search** — full-text search over channel history -- [ ] **User info command** — WHOIS-equivalent for querying user metadata +- [x] **User info command** — WHOIS for querying user info and channels - [ ] **Connection flood protection** — per-IP connection limits as a complement to hashcash - [ ] **Invite system** — `INVITE` command for `+i` channels diff --git a/internal/db/queries.go b/internal/db/queries.go index 193f639..38dd7f5 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -953,3 +953,125 @@ func (database *Database) GetSessionChannels( return scanChannels(rows) } + +// GetChannelCount returns the total number of channels. +func (database *Database) GetChannelCount( + ctx context.Context, +) (int64, error) { + var count int64 + + err := database.conn.QueryRowContext( + ctx, + "SELECT COUNT(*) FROM channels", + ).Scan(&count) + if err != nil { + return 0, fmt.Errorf( + "get channel count: %w", err, + ) + } + + return count, nil +} + +// ChannelInfoFull contains extended channel information. +type ChannelInfoFull struct { + ID int64 `json:"id"` + Name string `json:"name"` + Topic string `json:"topic"` + MemberCount int64 `json:"memberCount"` +} + +// ListAllChannelsWithCounts returns every channel +// with its member count. +func (database *Database) ListAllChannelsWithCounts( + ctx context.Context, +) ([]ChannelInfoFull, error) { + rows, err := database.conn.QueryContext(ctx, + `SELECT c.id, c.name, c.topic, + COUNT(cm.session_id) AS member_count + FROM channels c + LEFT JOIN channel_members cm + ON cm.channel_id = c.id + GROUP BY c.id + ORDER BY c.name`) + if err != nil { + return nil, fmt.Errorf( + "list channels with counts: %w", err, + ) + } + + defer func() { _ = rows.Close() }() + + var out []ChannelInfoFull + + for rows.Next() { + var chanInfo ChannelInfoFull + + err = rows.Scan( + &chanInfo.ID, &chanInfo.Name, + &chanInfo.Topic, &chanInfo.MemberCount, + ) + if err != nil { + return nil, fmt.Errorf( + "scan channel full: %w", err, + ) + } + + out = append(out, chanInfo) + } + + err = rows.Err() + if err != nil { + return nil, fmt.Errorf("rows error: %w", err) + } + + if out == nil { + out = []ChannelInfoFull{} + } + + return out, nil +} + +// GetChannelCreatedAt returns the creation time of a +// channel. +func (database *Database) GetChannelCreatedAt( + ctx context.Context, + channelID int64, +) (time.Time, error) { + var createdAt time.Time + + err := database.conn.QueryRowContext( + ctx, + "SELECT created_at FROM channels WHERE id = ?", + channelID, + ).Scan(&createdAt) + if err != nil { + return time.Time{}, fmt.Errorf( + "get channel created_at: %w", err, + ) + } + + return createdAt, nil +} + +// GetSessionCreatedAt returns the creation time of a +// session. +func (database *Database) GetSessionCreatedAt( + ctx context.Context, + sessionID int64, +) (time.Time, error) { + var createdAt time.Time + + err := database.conn.QueryRowContext( + ctx, + "SELECT created_at FROM sessions WHERE id = ?", + sessionID, + ).Scan(&createdAt) + if err != nil { + return time.Time{}, fmt.Errorf( + "get session created_at: %w", err, + ) + } + + return createdAt, nil +} diff --git a/internal/globals/globals.go b/internal/globals/globals.go index 235abb4..789a878 100644 --- a/internal/globals/globals.go +++ b/internal/globals/globals.go @@ -2,6 +2,8 @@ package globals import ( + "time" + "go.uber.org/fx" ) @@ -15,16 +17,18 @@ var ( // Globals holds application-wide metadata. type Globals struct { - Appname string - Version string + Appname string + Version string + StartTime time.Time } // New creates a new Globals instance from the global state. func New(_ fx.Lifecycle) (*Globals, error) { - n := &Globals{ - Appname: Appname, - Version: Version, + result := &Globals{ + Appname: Appname, + Version: Version, + StartTime: time.Now(), } - return n, nil + return result, nil } diff --git a/internal/handlers/api.go b/internal/handlers/api.go index ce752a2..ec8cbec 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -219,19 +219,130 @@ func (hdlr *Handlers) handleCreateSessionError( ) } -// deliverWelcome sends the RPL_WELCOME (001) numeric to a -// new client. +// deliverWelcome sends connection registration numerics +// (001-005) to a new client. func (hdlr *Handlers) deliverWelcome( request *http.Request, clientID int64, nick string, ) { ctx := request.Context() + srvName := hdlr.serverName() + version := hdlr.serverVersion() + // 001 RPL_WELCOME hdlr.enqueueNumeric( ctx, clientID, "001", nick, nil, "Welcome to the network, "+nick, ) + + // 002 RPL_YOURHOST + hdlr.enqueueNumeric( + ctx, clientID, "002", nick, nil, + "Your host is "+srvName+ + ", running version "+version, + ) + + // 003 RPL_CREATED + hdlr.enqueueNumeric( + ctx, clientID, "003", nick, nil, + "This server was created "+ + hdlr.params.Globals.StartTime. + Format("2006-01-02"), + ) + + // 004 RPL_MYINFO + hdlr.enqueueNumeric( + ctx, clientID, "004", nick, + []string{srvName, version, "", "imnst"}, + "", + ) + + // 005 RPL_ISUPPORT + hdlr.enqueueNumeric( + ctx, clientID, "005", nick, + []string{ + "CHANTYPES=#", + "NICKLEN=32", + "CHANMODES=,,," + "imnst", + "NETWORK=neoirc", + "CASEMAPPING=ascii", + }, + "are supported by this server", + ) + + // LUSERS + hdlr.deliverLusers(ctx, clientID, nick) +} + +// deliverLusers sends RPL_LUSERCLIENT (251), +// RPL_LUSEROP (252), RPL_LUSERCHANNELS (254), and +// RPL_LUSERME (255) to the client. +func (hdlr *Handlers) deliverLusers( + ctx context.Context, + clientID int64, + nick string, +) { + userCount, err := hdlr.params.Database.GetUserCount(ctx) + if err != nil { + hdlr.log.Error( + "lusers user count", "error", err, + ) + + userCount = 0 + } + + chanCount, err := hdlr.params.Database.GetChannelCount( + ctx, + ) + if err != nil { + hdlr.log.Error( + "lusers channel count", "error", err, + ) + + chanCount = 0 + } + + // 251 RPL_LUSERCLIENT + hdlr.enqueueNumeric( + ctx, clientID, "251", nick, nil, + fmt.Sprintf( + "There are %d users and 0 invisible on 1 servers", + userCount, + ), + ) + + // 252 RPL_LUSEROP + hdlr.enqueueNumeric( + ctx, clientID, "252", nick, + []string{"0"}, + "operator(s) online", + ) + + // 254 RPL_LUSERCHANNELS + hdlr.enqueueNumeric( + ctx, clientID, "254", nick, + []string{strconv.FormatInt(chanCount, 10)}, + "channels formed", + ) + + // 255 RPL_LUSERME + hdlr.enqueueNumeric( + ctx, clientID, "255", nick, nil, + fmt.Sprintf( + "I have %d clients and 1 servers", + userCount, + ), + ) +} + +func (hdlr *Handlers) serverVersion() string { + ver := hdlr.params.Globals.Version + if ver == "" { + return "dev" + } + + return ver } // deliverMOTD sends the MOTD as IRC numeric messages to a @@ -676,6 +787,55 @@ func (hdlr *Handlers) dispatchCommand( sessionID, clientID, nick, command, target, bodyLines, ) + default: + hdlr.dispatchQueryCommand( + writer, request, + sessionID, clientID, nick, + command, target, bodyLines, + ) + } +} + +func (hdlr *Handlers) dispatchQueryCommand( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick, command, target string, + bodyLines func() []string, +) { + switch command { + case "MODE": + hdlr.handleMode( + writer, request, + sessionID, clientID, nick, + target, bodyLines, + ) + case "NAMES": + hdlr.handleNames( + writer, request, + sessionID, clientID, nick, target, + ) + case "LIST": + hdlr.handleList( + writer, request, + sessionID, clientID, nick, + ) + case "WHOIS": + hdlr.handleWhois( + writer, request, + sessionID, clientID, nick, + target, bodyLines, + ) + case "WHO": + hdlr.handleWho( + writer, request, + sessionID, clientID, nick, target, + ) + case "LUSERS": + hdlr.handleLusers( + writer, request, + sessionID, clientID, nick, + ) default: hdlr.enqueueNumeric( request.Context(), clientID, @@ -1712,6 +1872,424 @@ func (hdlr *Handlers) handleQuit( http.StatusOK) } +// handleMode handles the MODE command for channels and +// users. Currently supports query-only (no mode changes). +func (hdlr *Handlers) handleMode( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick, target string, + bodyLines func() []string, +) { + if target == "" { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + "461", nick, []string{"MODE"}, + "Not enough parameters", + ) + + return + } + + channel := target + if !strings.HasPrefix(channel, "#") { + // User mode query — return empty modes. + hdlr.enqueueNumeric( + request.Context(), clientID, + "221", nick, nil, "+", + ) + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) + + return + } + + _ = bodyLines + + hdlr.handleChannelMode( + writer, request, + sessionID, clientID, nick, channel, + ) +} + +func (hdlr *Handlers) handleChannelMode( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick, channel string, +) { + ctx := request.Context() + + chID, err := hdlr.params.Database.GetChannelByName( + ctx, channel, + ) + if err != nil { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + "403", nick, []string{channel}, + "No such channel", + ) + + return + } + + // 324 RPL_CHANNELMODEIS + hdlr.enqueueNumeric( + ctx, clientID, "324", nick, + []string{channel, "+n"}, "", + ) + + // 329 RPL_CREATIONTIME + createdAt, timeErr := hdlr.params.Database. + GetChannelCreatedAt(ctx, chID) + if timeErr == nil { + hdlr.enqueueNumeric( + ctx, clientID, "329", nick, + []string{ + channel, + strconv.FormatInt( + createdAt.Unix(), 10, + ), + }, + "", + ) + } + + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) +} + +// handleNames sends NAMES reply for a channel. +func (hdlr *Handlers) handleNames( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick, target string, +) { + if target == "" { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + "461", nick, []string{"NAMES"}, + "Not enough parameters", + ) + + return + } + + channel := target + if !strings.HasPrefix(channel, "#") { + channel = "#" + channel + } + + ctx := request.Context() + + chID, err := hdlr.params.Database.GetChannelByName( + ctx, channel, + ) + if err != nil { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + "403", nick, []string{channel}, + "No such channel", + ) + + return + } + + 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) + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) +} + +// handleList sends the LIST response with 322/323 +// numerics. +func (hdlr *Handlers) handleList( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick string, +) { + ctx := request.Context() + + channels, err := hdlr.params.Database. + ListAllChannelsWithCounts(ctx) + if err != nil { + hdlr.log.Error( + "list channels failed", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) + + return + } + + for _, chanInfo := range channels { + // 322 RPL_LIST + hdlr.enqueueNumeric( + ctx, clientID, "322", nick, + []string{ + chanInfo.Name, + strconv.FormatInt( + chanInfo.MemberCount, 10, + ), + }, + chanInfo.Topic, + ) + } + + // 323 — end of channel list. + hdlr.enqueueNumeric( + ctx, clientID, "323", nick, nil, + "End of /LIST", + ) + + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) +} + +// handleWhois handles the WHOIS command. +func (hdlr *Handlers) handleWhois( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick, target string, + bodyLines func() []string, +) { + queryNick := target + + // If target is empty, check body for the nick. + if queryNick == "" { + lines := bodyLines() + if len(lines) > 0 { + queryNick = strings.TrimSpace(lines[0]) + } + } + + if queryNick == "" { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + "461", nick, []string{"WHOIS"}, + "Not enough parameters", + ) + + return + } + + hdlr.executeWhois( + writer, request, + sessionID, clientID, nick, queryNick, + ) +} + +func (hdlr *Handlers) executeWhois( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick, queryNick string, +) { + ctx := request.Context() + srvName := hdlr.serverName() + + targetSID, err := hdlr.params.Database.GetSessionByNick( + ctx, queryNick, + ) + if err != nil { + hdlr.enqueueNumeric( + ctx, clientID, "401", nick, + []string{queryNick}, + "No such nick/channel", + ) + hdlr.enqueueNumeric( + ctx, clientID, "318", nick, + []string{queryNick}, + "End of /WHOIS list", + ) + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) + + return + } + + // 311 RPL_WHOISUSER + hdlr.enqueueNumeric( + ctx, clientID, "311", nick, + []string{queryNick, queryNick, srvName, "*"}, + queryNick, + ) + + // 312 RPL_WHOISSERVER + hdlr.enqueueNumeric( + ctx, clientID, "312", nick, + []string{queryNick, srvName}, + "neoirc server", + ) + + // 319 RPL_WHOISCHANNELS + hdlr.deliverWhoisChannels( + ctx, clientID, nick, queryNick, targetSID, + ) + + // 318 RPL_ENDOFWHOIS + hdlr.enqueueNumeric( + ctx, clientID, "318", nick, + []string{queryNick}, + "End of /WHOIS list", + ) + + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) +} + +func (hdlr *Handlers) deliverWhoisChannels( + ctx context.Context, + clientID int64, + nick, queryNick string, + targetSID int64, +) { + channels, chanErr := hdlr.params.Database. + GetSessionChannels(ctx, targetSID) + if chanErr != nil || len(channels) == 0 { + return + } + + chanNames := make([]string, 0, len(channels)) + + for _, chanInfo := range channels { + chanNames = append(chanNames, chanInfo.Name) + } + + hdlr.enqueueNumeric( + ctx, clientID, "319", nick, + []string{queryNick}, + strings.Join(chanNames, " "), + ) +} + +// handleWho handles the WHO command. +func (hdlr *Handlers) handleWho( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick, target string, +) { + if target == "" { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + "461", nick, []string{"WHO"}, + "Not enough parameters", + ) + + return + } + + ctx := request.Context() + srvName := hdlr.serverName() + + channel := target + if !strings.HasPrefix(channel, "#") { + channel = "#" + channel + } + + chID, err := hdlr.params.Database.GetChannelByName( + ctx, channel, + ) + if err != nil { + // 315 RPL_ENDOFWHO (empty result) + hdlr.enqueueNumeric( + ctx, clientID, "315", nick, + []string{target}, + "End of /WHO list", + ) + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) + + return + } + + members, memErr := hdlr.params.Database.ChannelMembers( + ctx, chID, + ) + if memErr == nil { + for _, mem := range members { + // 352 RPL_WHOREPLY + hdlr.enqueueNumeric( + ctx, clientID, "352", nick, + []string{ + channel, mem.Nick, srvName, + srvName, mem.Nick, "H", + }, + "0 "+mem.Nick, + ) + } + } + + // 315 RPL_ENDOFWHO + hdlr.enqueueNumeric( + ctx, clientID, "315", nick, + []string{channel}, + "End of /WHO list", + ) + + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) +} + +// handleLusers handles the LUSERS command. +func (hdlr *Handlers) handleLusers( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick string, +) { + hdlr.deliverLusers( + request.Context(), clientID, nick, + ) + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) +} + // HandleGetHistory returns message history for a target. func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc { return func( diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index b19587b..8541c89 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -115,8 +115,9 @@ func newTestServer( func newTestGlobals() *globals.Globals { return &globals.Globals{ - Appname: "neoirc-test", - Version: "test", + Appname: "neoirc-test", + Version: "test", + StartTime: time.Now(), } } -- 2.49.1 From a193831ed475d0298a89ffd6f65fe77ed1caab17 Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 9 Mar 2026 15:07:02 -0700 Subject: [PATCH 2/4] fix: extract repeated IRC command string literals to constants (goconst) --- internal/handlers/api.go | 62 +++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/internal/handlers/api.go b/internal/handlers/api.go index ec8cbec..ca96da2 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -27,7 +27,15 @@ const ( defaultMaxBodySize = 4096 defaultHistLimit = 50 maxHistLimit = 500 + cmdJoin = "JOIN" + cmdList = "LIST" + cmdNick = "NICK" + cmdPart = "PART" cmdPrivmsg = "PRIVMSG" + cmdQuit = "QUIT" + cmdTopic = "TOPIC" + cmdWho = "WHO" + cmdWhois = "WHOIS" ) func (hdlr *Handlers) maxBodySize() int64 { @@ -756,32 +764,32 @@ func (hdlr *Handlers) dispatchCommand( sessionID, clientID, nick, command, target, body, bodyLines, ) - case "JOIN": + case cmdJoin: hdlr.handleJoin( writer, request, sessionID, clientID, nick, target, ) - case "PART": + case cmdPart: hdlr.handlePart( writer, request, sessionID, clientID, nick, target, body, ) - case "NICK": + case cmdNick: hdlr.handleNick( writer, request, sessionID, clientID, nick, bodyLines, ) - case "TOPIC": + case cmdTopic: hdlr.handleTopic( writer, request, sessionID, clientID, nick, target, body, bodyLines, ) - case "QUIT": + case cmdQuit: hdlr.handleQuit( writer, request, sessionID, nick, body, ) - case "MOTD", "LIST", "WHO", "WHOIS", "PING": + case "MOTD", cmdList, cmdWho, cmdWhois, "PING": hdlr.dispatchInfoCommand( writer, request, sessionID, clientID, nick, @@ -815,18 +823,18 @@ func (hdlr *Handlers) dispatchQueryCommand( writer, request, sessionID, clientID, nick, target, ) - case "LIST": + case cmdList: hdlr.handleList( writer, request, sessionID, clientID, nick, ) - case "WHOIS": + case cmdWhois: hdlr.handleWhois( writer, request, sessionID, clientID, nick, target, bodyLines, ) - case "WHO": + case cmdWho: hdlr.handleWho( writer, request, sessionID, clientID, nick, target, @@ -1073,7 +1081,7 @@ func (hdlr *Handlers) handleJoin( if target == "" { hdlr.respondIRCError( writer, request, clientID, sessionID, - "461", nick, []string{"JOIN"}, + "461", nick, []string{cmdJoin}, "Not enough parameters", ) @@ -1144,7 +1152,7 @@ func (hdlr *Handlers) executeJoin( ) _ = hdlr.fanOutSilent( - request, "JOIN", nick, channel, nil, memberIDs, + request, cmdJoin, nick, channel, nil, memberIDs, ) hdlr.deliverJoinNumerics( @@ -1242,7 +1250,7 @@ func (hdlr *Handlers) handlePart( if target == "" { hdlr.enqueueNumeric( request.Context(), clientID, - "461", nick, []string{"PART"}, + "461", nick, []string{cmdPart}, "Not enough parameters", ) hdlr.broker.Notify(sessionID) @@ -1280,7 +1288,7 @@ func (hdlr *Handlers) handlePart( ) _ = hdlr.fanOutSilent( - request, "PART", nick, channel, body, memberIDs, + request, cmdPart, nick, channel, body, memberIDs, ) err = hdlr.params.Database.PartChannel( @@ -1322,7 +1330,7 @@ func (hdlr *Handlers) handleNick( if len(lines) == 0 { hdlr.respondIRCError( writer, request, clientID, sessionID, - "461", nick, []string{"NICK"}, + "461", nick, []string{cmdNick}, "Not enough parameters", ) @@ -1420,7 +1428,7 @@ func (hdlr *Handlers) broadcastNick( } dbID, _, _ := hdlr.params.Database.InsertMessage( - request.Context(), "NICK", oldNick, "", + request.Context(), cmdNick, oldNick, "", nil, json.RawMessage(nickBody), nil, ) @@ -1461,7 +1469,7 @@ func (hdlr *Handlers) handleTopic( if target == "" { hdlr.respondIRCError( writer, request, clientID, sessionID, - "461", nick, []string{"TOPIC"}, + "461", nick, []string{cmdTopic}, "Not enough parameters", ) @@ -1472,7 +1480,7 @@ func (hdlr *Handlers) handleTopic( if len(lines) == 0 { hdlr.respondIRCError( writer, request, clientID, sessionID, - "461", nick, []string{"TOPIC"}, + "461", nick, []string{cmdTopic}, "Not enough parameters", ) @@ -1534,7 +1542,7 @@ func (hdlr *Handlers) executeTopic( ) _ = hdlr.fanOutSilent( - request, "TOPIC", nick, channel, body, memberIDs, + request, cmdTopic, nick, channel, body, memberIDs, ) hdlr.enqueueNumeric( @@ -1567,16 +1575,16 @@ func (hdlr *Handlers) dispatchInfoCommand( hdlr.deliverMOTD( request, clientID, sessionID, nick, ) - case "LIST": + case cmdList: hdlr.handleListCmd( request, clientID, sessionID, nick, ) - case "WHO": + case cmdWho: hdlr.handleWhoCmd( request, clientID, sessionID, nick, target, ) - case "WHOIS": + case cmdWhois: hdlr.handleWhoisCmd( request, clientID, sessionID, nick, target, bodyLines, @@ -1657,7 +1665,7 @@ func (hdlr *Handlers) handleWhoCmd( if target == "" { hdlr.enqueueNumeric( ctx, clientID, "461", nick, - []string{"WHO"}, "Not enough parameters", + []string{cmdWho}, "Not enough parameters", ) hdlr.broker.Notify(sessionID) @@ -1728,7 +1736,7 @@ func (hdlr *Handlers) handleWhoisCmd( if whoisNick == "" { hdlr.enqueueNumeric( ctx, clientID, "461", nick, - []string{"WHOIS"}, "Not enough parameters", + []string{cmdWhois}, "Not enough parameters", ) hdlr.broker.Notify(sessionID) @@ -1831,7 +1839,7 @@ func (hdlr *Handlers) handleQuit( if len(channels) > 0 { dbID, _, _ = hdlr.params.Database.InsertMessage( - request.Context(), "QUIT", nick, "", + request.Context(), cmdQuit, nick, "", nil, body, nil, ) } @@ -2100,7 +2108,7 @@ func (hdlr *Handlers) handleWhois( if queryNick == "" { hdlr.respondIRCError( writer, request, clientID, sessionID, - "461", nick, []string{"WHOIS"}, + "461", nick, []string{cmdWhois}, "Not enough parameters", ) @@ -2211,7 +2219,7 @@ func (hdlr *Handlers) handleWho( if target == "" { hdlr.respondIRCError( writer, request, clientID, sessionID, - "461", nick, []string{"WHO"}, + "461", nick, []string{cmdWho}, "Not enough parameters", ) @@ -2499,7 +2507,7 @@ func (hdlr *Handlers) cleanupUser( if len(channels) > 0 { quitDBID, _, _ = hdlr.params.Database.InsertMessage( - ctx, "QUIT", nick, "", + ctx, cmdQuit, nick, "", nil, nil, nil, ) } -- 2.49.1 From 5efb4b6949f0f8ca5ff70568f415955de84d1857 Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 9 Mar 2026 15:23:00 -0700 Subject: [PATCH 3/4] refactor: replace all bare command string literals with named constants Extract cmdLusers, cmdMode, cmdMotd, cmdNames, cmdNotice, cmdPing, cmdPong constants in internal/handlers/api.go. Add corresponding constants in cmd/neoirc-cli/main.go and cmd/neoirc-cli/api/client.go. Replace every bare IRC command string literal in switch cases and command dispatch code with the named constant. Zero bare command strings remain in any dispatch path. --- cmd/neoirc-cli/api/client.go | 7 ++++-- cmd/neoirc-cli/main.go | 43 ++++++++++++++++++++++-------------- internal/handlers/api.go | 27 +++++++++++++--------- 3 files changed, 49 insertions(+), 28 deletions(-) diff --git a/cmd/neoirc-cli/api/client.go b/cmd/neoirc-cli/api/client.go index 98eea62..a85a244 100644 --- a/cmd/neoirc-cli/api/client.go +++ b/cmd/neoirc-cli/api/client.go @@ -19,6 +19,9 @@ const ( httpTimeout = 30 * time.Second pollExtraTime = 5 httpErrThreshold = 400 + + cmdJoin = "JOIN" + cmdPart = "PART" ) var errHTTP = errors.New("HTTP error") @@ -168,7 +171,7 @@ func (client *Client) PollMessages( func (client *Client) JoinChannel(channel string) error { return client.SendMessage( &Message{ //nolint:exhaustruct // only command+to needed - Command: "JOIN", To: channel, + Command: cmdJoin, To: channel, }, ) } @@ -177,7 +180,7 @@ func (client *Client) JoinChannel(channel string) error { func (client *Client) PartChannel(channel string) error { return client.SendMessage( &Message{ //nolint:exhaustruct // only command+to needed - Command: "PART", To: channel, + Command: cmdPart, To: channel, }, ) } diff --git a/cmd/neoirc-cli/main.go b/cmd/neoirc-cli/main.go index 2b81705..865937e 100644 --- a/cmd/neoirc-cli/main.go +++ b/cmd/neoirc-cli/main.go @@ -16,6 +16,17 @@ const ( pollTimeout = 15 pollRetry = 2 * time.Second timeFormat = "15:04" + + cmdJoin = "JOIN" + cmdMotd = "MOTD" + cmdNick = "NICK" + cmdNotice = "NOTICE" + cmdPart = "PART" + cmdPrivmsg = "PRIVMSG" + cmdQuit = "QUIT" + cmdTopic = "TOPIC" + cmdWho = "WHO" + cmdWhois = "WHOIS" ) // App holds the application state. @@ -86,7 +97,7 @@ func (a *App) handleInput(text string) { } err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct - Command: "PRIVMSG", + Command: cmdPrivmsg, To: target, Body: []string{text}, }) @@ -241,7 +252,7 @@ func (a *App) cmdNick(nick string) { } err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct - Command: "NICK", + Command: cmdNick, Body: []string{nick}, }) if err != nil { @@ -376,7 +387,7 @@ func (a *App) cmdMsg(args string) { } err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct - Command: "PRIVMSG", + Command: cmdPrivmsg, To: target, Body: []string{text}, }) @@ -434,7 +445,7 @@ func (a *App) cmdTopic(args string) { if args == "" { err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct - Command: "TOPIC", + Command: cmdTopic, To: target, }) if err != nil { @@ -447,7 +458,7 @@ func (a *App) cmdTopic(args string) { } err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct - Command: "TOPIC", + Command: cmdTopic, To: target, Body: []string{args}, }) @@ -535,7 +546,7 @@ func (a *App) cmdMotd() { } err := a.client.SendMessage( - &api.Message{Command: "MOTD"}, //nolint:exhaustruct + &api.Message{Command: cmdMotd}, //nolint:exhaustruct ) if err != nil { a.ui.AddStatus(fmt.Sprintf( @@ -572,7 +583,7 @@ func (a *App) cmdWho(args string) { err := a.client.SendMessage( &api.Message{ //nolint:exhaustruct - Command: "WHO", To: channel, + Command: cmdWho, To: channel, }, ) if err != nil { @@ -603,7 +614,7 @@ func (a *App) cmdWhois(args string) { err := a.client.SendMessage( &api.Message{ //nolint:exhaustruct - Command: "WHOIS", To: args, + Command: cmdWhois, To: args, }, ) if err != nil { @@ -653,7 +664,7 @@ func (a *App) cmdQuit() { if a.connected && a.client != nil { _ = a.client.SendMessage( - &api.Message{Command: "QUIT"}, //nolint:exhaustruct + &api.Message{Command: cmdQuit}, //nolint:exhaustruct ) } @@ -738,19 +749,19 @@ func (a *App) handleServerMessage(msg *api.Message) { a.mu.Unlock() switch msg.Command { - case "PRIVMSG": + case cmdPrivmsg: a.handlePrivmsgEvent(msg, timestamp, myNick) - case "JOIN": + case cmdJoin: a.handleJoinEvent(msg, timestamp) - case "PART": + case cmdPart: a.handlePartEvent(msg, timestamp) - case "QUIT": + case cmdQuit: a.handleQuitEvent(msg, timestamp) - case "NICK": + case cmdNick: a.handleNickEvent(msg, timestamp, myNick) - case "NOTICE": + case cmdNotice: a.handleNoticeEvent(msg, timestamp) - case "TOPIC": + case cmdTopic: a.handleTopicEvent(msg, timestamp) default: a.handleDefaultEvent(msg, timestamp) diff --git a/internal/handlers/api.go b/internal/handlers/api.go index ca96da2..6b1387a 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -29,8 +29,15 @@ const ( maxHistLimit = 500 cmdJoin = "JOIN" cmdList = "LIST" + cmdLusers = "LUSERS" + cmdMode = "MODE" + cmdMotd = "MOTD" + cmdNames = "NAMES" cmdNick = "NICK" + cmdNotice = "NOTICE" cmdPart = "PART" + cmdPing = "PING" + cmdPong = "PONG" cmdPrivmsg = "PRIVMSG" cmdQuit = "QUIT" cmdTopic = "TOPIC" @@ -758,7 +765,7 @@ func (hdlr *Handlers) dispatchCommand( bodyLines func() []string, ) { switch command { - case cmdPrivmsg, "NOTICE": + case cmdPrivmsg, cmdNotice: hdlr.handlePrivmsg( writer, request, sessionID, clientID, nick, @@ -789,7 +796,7 @@ func (hdlr *Handlers) dispatchCommand( hdlr.handleQuit( writer, request, sessionID, nick, body, ) - case "MOTD", cmdList, cmdWho, cmdWhois, "PING": + case cmdMotd, cmdList, cmdWho, cmdWhois, cmdPing: hdlr.dispatchInfoCommand( writer, request, sessionID, clientID, nick, @@ -812,13 +819,13 @@ func (hdlr *Handlers) dispatchQueryCommand( bodyLines func() []string, ) { switch command { - case "MODE": + case cmdMode: hdlr.handleMode( writer, request, sessionID, clientID, nick, target, bodyLines, ) - case "NAMES": + case cmdNames: hdlr.handleNames( writer, request, sessionID, clientID, nick, target, @@ -839,7 +846,7 @@ func (hdlr *Handlers) dispatchQueryCommand( writer, request, sessionID, clientID, nick, target, ) - case "LUSERS": + case cmdLusers: hdlr.handleLusers( writer, request, sessionID, clientID, nick, @@ -1571,7 +1578,7 @@ func (hdlr *Handlers) dispatchInfoCommand( okResp := map[string]string{"status": "ok"} switch command { - case "MOTD": + case cmdMotd: hdlr.deliverMOTD( request, clientID, sessionID, nick, ) @@ -1589,10 +1596,10 @@ func (hdlr *Handlers) dispatchInfoCommand( request, clientID, sessionID, nick, target, bodyLines, ) - case "PING": + case cmdPing: hdlr.respondJSON(writer, request, map[string]string{ - "command": "PONG", + "command": cmdPong, "from": hdlr.serverName(), }, http.StatusOK) @@ -1892,7 +1899,7 @@ func (hdlr *Handlers) handleMode( if target == "" { hdlr.respondIRCError( writer, request, clientID, sessionID, - "461", nick, []string{"MODE"}, + "461", nick, []string{cmdMode}, "Not enough parameters", ) @@ -1981,7 +1988,7 @@ func (hdlr *Handlers) handleNames( if target == "" { hdlr.respondIRCError( writer, request, clientID, sessionID, - "461", nick, []string{"NAMES"}, + "461", nick, []string{cmdNames}, "Not enough parameters", ) -- 2.49.1 From 7bbd6de73aa08794358096cf5b3a8800012f16fd Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 9 Mar 2026 15:49:29 -0700 Subject: [PATCH 4/4] feat: add irc numerics package, deduplicate constants, fix dead code - Create internal/irc/ package with all IRC numeric reply codes (RFC 1459/2812) and command string constants as the single source of truth - Replace all 69+ bare numeric string literals in api.go with named constants (e.g. irc.RplWelcome, irc.ErrNoSuchChannel) - Add 'code' (int) and named 'command' (e.g. RPL_YOURHOST) fields to IRC message JSON replies via irc.Name() lookup in scanMessages - Deduplicate command constants: remove local definitions from api.go, cmd/neoirc-cli/main.go, and cmd/neoirc-cli/api/client.go; all now import from internal/irc - Fix dead code: remove handleListCmd/handleWhoCmd/handleWhoisCmd/ sendWhoisNumerics that were unreachable due to dispatchCommand routing LIST/WHO/WHOIS to dispatchInfoCommand before dispatchQueryCommand. Route these commands to dispatchQueryCommand which has the improved implementations (e.g. ListAllChannelsWithCounts single-query vs N+1) - Update enqueueNumeric and respondIRCError signatures from string to int - Update test helper findNumeric to check the new 'code' JSON field Closes #52 --- cmd/neoirc-cli/api/client.go | 9 +- cmd/neoirc-cli/main.go | 44 ++-- internal/db/queries.go | 21 ++ internal/handlers/api.go | 417 ++++++++-------------------------- internal/handlers/api_test.go | 6 +- internal/irc/commands.go | 21 ++ internal/irc/numerics.go | 150 ++++++++++++ 7 files changed, 307 insertions(+), 361 deletions(-) create mode 100644 internal/irc/commands.go create mode 100644 internal/irc/numerics.go diff --git a/cmd/neoirc-cli/api/client.go b/cmd/neoirc-cli/api/client.go index a85a244..8f7fdcf 100644 --- a/cmd/neoirc-cli/api/client.go +++ b/cmd/neoirc-cli/api/client.go @@ -13,15 +13,14 @@ import ( "strconv" "strings" "time" + + "git.eeqj.de/sneak/neoirc/internal/irc" ) const ( httpTimeout = 30 * time.Second pollExtraTime = 5 httpErrThreshold = 400 - - cmdJoin = "JOIN" - cmdPart = "PART" ) var errHTTP = errors.New("HTTP error") @@ -171,7 +170,7 @@ func (client *Client) PollMessages( func (client *Client) JoinChannel(channel string) error { return client.SendMessage( &Message{ //nolint:exhaustruct // only command+to needed - Command: cmdJoin, To: channel, + Command: irc.CmdJoin, To: channel, }, ) } @@ -180,7 +179,7 @@ func (client *Client) JoinChannel(channel string) error { func (client *Client) PartChannel(channel string) error { return client.SendMessage( &Message{ //nolint:exhaustruct // only command+to needed - Command: cmdPart, To: channel, + Command: irc.CmdPart, To: channel, }, ) } diff --git a/cmd/neoirc-cli/main.go b/cmd/neoirc-cli/main.go index 865937e..4000e01 100644 --- a/cmd/neoirc-cli/main.go +++ b/cmd/neoirc-cli/main.go @@ -9,6 +9,7 @@ import ( "time" api "git.eeqj.de/sneak/neoirc/cmd/neoirc-cli/api" + "git.eeqj.de/sneak/neoirc/internal/irc" ) const ( @@ -16,17 +17,6 @@ const ( pollTimeout = 15 pollRetry = 2 * time.Second timeFormat = "15:04" - - cmdJoin = "JOIN" - cmdMotd = "MOTD" - cmdNick = "NICK" - cmdNotice = "NOTICE" - cmdPart = "PART" - cmdPrivmsg = "PRIVMSG" - cmdQuit = "QUIT" - cmdTopic = "TOPIC" - cmdWho = "WHO" - cmdWhois = "WHOIS" ) // App holds the application state. @@ -97,7 +87,7 @@ func (a *App) handleInput(text string) { } err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct - Command: cmdPrivmsg, + Command: irc.CmdPrivmsg, To: target, Body: []string{text}, }) @@ -252,7 +242,7 @@ func (a *App) cmdNick(nick string) { } err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct - Command: cmdNick, + Command: irc.CmdNick, Body: []string{nick}, }) if err != nil { @@ -387,7 +377,7 @@ func (a *App) cmdMsg(args string) { } err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct - Command: cmdPrivmsg, + Command: irc.CmdPrivmsg, To: target, Body: []string{text}, }) @@ -445,7 +435,7 @@ func (a *App) cmdTopic(args string) { if args == "" { err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct - Command: cmdTopic, + Command: irc.CmdTopic, To: target, }) if err != nil { @@ -458,7 +448,7 @@ func (a *App) cmdTopic(args string) { } err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct - Command: cmdTopic, + Command: irc.CmdTopic, To: target, Body: []string{args}, }) @@ -546,7 +536,7 @@ func (a *App) cmdMotd() { } err := a.client.SendMessage( - &api.Message{Command: cmdMotd}, //nolint:exhaustruct + &api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct ) if err != nil { a.ui.AddStatus(fmt.Sprintf( @@ -583,7 +573,7 @@ func (a *App) cmdWho(args string) { err := a.client.SendMessage( &api.Message{ //nolint:exhaustruct - Command: cmdWho, To: channel, + Command: irc.CmdWho, To: channel, }, ) if err != nil { @@ -614,7 +604,7 @@ func (a *App) cmdWhois(args string) { err := a.client.SendMessage( &api.Message{ //nolint:exhaustruct - Command: cmdWhois, To: args, + Command: irc.CmdWhois, To: args, }, ) if err != nil { @@ -664,7 +654,7 @@ func (a *App) cmdQuit() { if a.connected && a.client != nil { _ = a.client.SendMessage( - &api.Message{Command: cmdQuit}, //nolint:exhaustruct + &api.Message{Command: irc.CmdQuit}, //nolint:exhaustruct ) } @@ -749,19 +739,19 @@ func (a *App) handleServerMessage(msg *api.Message) { a.mu.Unlock() switch msg.Command { - case cmdPrivmsg: + case irc.CmdPrivmsg: a.handlePrivmsgEvent(msg, timestamp, myNick) - case cmdJoin: + case irc.CmdJoin: a.handleJoinEvent(msg, timestamp) - case cmdPart: + case irc.CmdPart: a.handlePartEvent(msg, timestamp) - case cmdQuit: + case irc.CmdQuit: a.handleQuitEvent(msg, timestamp) - case cmdNick: + case irc.CmdNick: a.handleNickEvent(msg, timestamp, myNick) - case cmdNotice: + case irc.CmdNotice: a.handleNoticeEvent(msg, timestamp) - case cmdTopic: + case irc.CmdTopic: a.handleTopicEvent(msg, timestamp) default: a.handleDefaultEvent(msg, timestamp) diff --git a/internal/db/queries.go b/internal/db/queries.go index 38dd7f5..6ffff23 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -7,8 +7,10 @@ import ( "encoding/hex" "encoding/json" "fmt" + "strconv" "time" + "git.eeqj.de/sneak/neoirc/internal/irc" "github.com/google/uuid" ) @@ -33,6 +35,7 @@ func generateToken() (string, error) { type IRCMessage struct { ID string `json:"id"` Command string `json:"command"` + Code int `json:"code,omitempty"` From string `json:"from,omitempty"` To string `json:"to,omitempty"` Params json.RawMessage `json:"params,omitempty"` @@ -42,6 +45,15 @@ type IRCMessage struct { DBID int64 `json:"-"` } +// isNumericCode returns true if s is exactly a 3-digit +// IRC numeric reply code. +func isNumericCode(s string) bool { + return len(s) == 3 && + s[0] >= '0' && s[0] <= '9' && + s[1] >= '0' && s[1] <= '9' && + s[2] >= '0' && s[2] <= '9' +} + // ChannelInfo is a lightweight channel representation. type ChannelInfo struct { ID int64 `json:"id"` @@ -717,6 +729,15 @@ func scanMessages( msg.DBID = qID lastQID = qID + if isNumericCode(msg.Command) { + code, _ := strconv.Atoi(msg.Command) + msg.Code = code + + if name := irc.Name(code); name != "" { + msg.Command = name + } + } + msgs = append(msgs, msg) } diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 6b1387a..a71e31b 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "git.eeqj.de/sneak/neoirc/internal/irc" "github.com/go-chi/chi" ) @@ -27,22 +28,6 @@ const ( defaultMaxBodySize = 4096 defaultHistLimit = 50 maxHistLimit = 500 - cmdJoin = "JOIN" - cmdList = "LIST" - cmdLusers = "LUSERS" - cmdMode = "MODE" - cmdMotd = "MOTD" - cmdNames = "NAMES" - cmdNick = "NICK" - cmdNotice = "NOTICE" - cmdPart = "PART" - cmdPing = "PING" - cmdPong = "PONG" - cmdPrivmsg = "PRIVMSG" - cmdQuit = "QUIT" - cmdTopic = "TOPIC" - cmdWho = "WHO" - cmdWhois = "WHOIS" ) func (hdlr *Handlers) maxBodySize() int64 { @@ -247,20 +232,20 @@ func (hdlr *Handlers) deliverWelcome( // 001 RPL_WELCOME hdlr.enqueueNumeric( - ctx, clientID, "001", nick, nil, + ctx, clientID, irc.RplWelcome, nick, nil, "Welcome to the network, "+nick, ) // 002 RPL_YOURHOST hdlr.enqueueNumeric( - ctx, clientID, "002", nick, nil, + ctx, clientID, irc.RplYourHost, nick, nil, "Your host is "+srvName+ ", running version "+version, ) // 003 RPL_CREATED hdlr.enqueueNumeric( - ctx, clientID, "003", nick, nil, + ctx, clientID, irc.RplCreated, nick, nil, "This server was created "+ hdlr.params.Globals.StartTime. Format("2006-01-02"), @@ -268,14 +253,14 @@ func (hdlr *Handlers) deliverWelcome( // 004 RPL_MYINFO hdlr.enqueueNumeric( - ctx, clientID, "004", nick, + ctx, clientID, irc.RplMyInfo, nick, []string{srvName, version, "", "imnst"}, "", ) // 005 RPL_ISUPPORT hdlr.enqueueNumeric( - ctx, clientID, "005", nick, + ctx, clientID, irc.RplIsupport, nick, []string{ "CHANTYPES=#", "NICKLEN=32", @@ -320,7 +305,7 @@ func (hdlr *Handlers) deliverLusers( // 251 RPL_LUSERCLIENT hdlr.enqueueNumeric( - ctx, clientID, "251", nick, nil, + ctx, clientID, irc.RplLuserClient, nick, nil, fmt.Sprintf( "There are %d users and 0 invisible on 1 servers", userCount, @@ -329,21 +314,21 @@ func (hdlr *Handlers) deliverLusers( // 252 RPL_LUSEROP hdlr.enqueueNumeric( - ctx, clientID, "252", nick, + ctx, clientID, irc.RplLuserOp, nick, []string{"0"}, "operator(s) online", ) // 254 RPL_LUSERCHANNELS hdlr.enqueueNumeric( - ctx, clientID, "254", nick, + ctx, clientID, irc.RplLuserChannels, nick, []string{strconv.FormatInt(chanCount, 10)}, "channels formed", ) // 255 RPL_LUSERME hdlr.enqueueNumeric( - ctx, clientID, "255", nick, nil, + ctx, clientID, irc.RplLuserMe, nick, nil, fmt.Sprintf( "I have %d clients and 1 servers", userCount, @@ -381,19 +366,19 @@ func (hdlr *Handlers) deliverMOTD( } hdlr.enqueueNumeric( - ctx, clientID, "375", nick, nil, + ctx, clientID, irc.RplMotdStart, nick, nil, "- "+srvName+" Message of the Day -", ) for line := range strings.SplitSeq(motd, "\n") { hdlr.enqueueNumeric( - ctx, clientID, "372", nick, nil, + ctx, clientID, irc.RplMotd, nick, nil, "- "+line, ) } hdlr.enqueueNumeric( - ctx, clientID, "376", nick, nil, + ctx, clientID, irc.RplEndOfMotd, nick, nil, "End of /MOTD command.", ) @@ -412,10 +397,13 @@ func (hdlr *Handlers) serverName() string { func (hdlr *Handlers) enqueueNumeric( ctx context.Context, clientID int64, - command, nick string, + code int, + nick string, params []string, text string, ) { + command := fmt.Sprintf("%03d", code) + body, err := json.Marshal([]string{text}) if err != nil { hdlr.log.Error( @@ -765,38 +753,38 @@ func (hdlr *Handlers) dispatchCommand( bodyLines func() []string, ) { switch command { - case cmdPrivmsg, cmdNotice: + case irc.CmdPrivmsg, irc.CmdNotice: hdlr.handlePrivmsg( writer, request, sessionID, clientID, nick, command, target, body, bodyLines, ) - case cmdJoin: + case irc.CmdJoin: hdlr.handleJoin( writer, request, sessionID, clientID, nick, target, ) - case cmdPart: + case irc.CmdPart: hdlr.handlePart( writer, request, sessionID, clientID, nick, target, body, ) - case cmdNick: + case irc.CmdNick: hdlr.handleNick( writer, request, sessionID, clientID, nick, bodyLines, ) - case cmdTopic: + case irc.CmdTopic: hdlr.handleTopic( writer, request, sessionID, clientID, nick, target, body, bodyLines, ) - case cmdQuit: + case irc.CmdQuit: hdlr.handleQuit( writer, request, sessionID, nick, body, ) - case cmdMotd, cmdList, cmdWho, cmdWhois, cmdPing: + case irc.CmdMotd, irc.CmdPing: hdlr.dispatchInfoCommand( writer, request, sessionID, clientID, nick, @@ -819,34 +807,34 @@ func (hdlr *Handlers) dispatchQueryCommand( bodyLines func() []string, ) { switch command { - case cmdMode: + case irc.CmdMode: hdlr.handleMode( writer, request, sessionID, clientID, nick, target, bodyLines, ) - case cmdNames: + case irc.CmdNames: hdlr.handleNames( writer, request, sessionID, clientID, nick, target, ) - case cmdList: + case irc.CmdList: hdlr.handleList( writer, request, sessionID, clientID, nick, ) - case cmdWhois: + case irc.CmdWhois: hdlr.handleWhois( writer, request, sessionID, clientID, nick, target, bodyLines, ) - case cmdWho: + case irc.CmdWho: hdlr.handleWho( writer, request, sessionID, clientID, nick, target, ) - case cmdLusers: + case irc.CmdLusers: hdlr.handleLusers( writer, request, sessionID, clientID, nick, @@ -854,7 +842,7 @@ func (hdlr *Handlers) dispatchQueryCommand( default: hdlr.enqueueNumeric( request.Context(), clientID, - "421", nick, []string{command}, + irc.ErrUnknownCommand, nick, []string{command}, "Unknown command", ) hdlr.broker.Notify(sessionID) @@ -875,7 +863,7 @@ func (hdlr *Handlers) handlePrivmsg( if target == "" { hdlr.enqueueNumeric( request.Context(), clientID, - "461", nick, []string{command}, + irc.ErrNeedMoreParams, nick, []string{command}, "Not enough parameters", ) hdlr.broker.Notify(sessionID) @@ -890,7 +878,7 @@ func (hdlr *Handlers) handlePrivmsg( if len(lines) == 0 { hdlr.enqueueNumeric( request.Context(), clientID, - "461", nick, []string{command}, + irc.ErrNeedMoreParams, nick, []string{command}, "Not enough parameters", ) hdlr.broker.Notify(sessionID) @@ -924,13 +912,14 @@ func (hdlr *Handlers) respondIRCError( writer http.ResponseWriter, request *http.Request, clientID, sessionID int64, - numeric, nick string, + code int, + nick string, params []string, text string, ) { hdlr.enqueueNumeric( request.Context(), clientID, - numeric, nick, params, text, + code, nick, params, text, ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, @@ -951,7 +940,7 @@ func (hdlr *Handlers) handleChannelMsg( if err != nil { hdlr.respondIRCError( writer, request, clientID, sessionID, - "403", nick, []string{target}, + irc.ErrNoSuchChannel, nick, []string{target}, "No such channel", ) @@ -977,7 +966,7 @@ func (hdlr *Handlers) handleChannelMsg( if !isMember { hdlr.respondIRCError( writer, request, clientID, sessionID, - "442", nick, []string{target}, + irc.ErrNotOnChannel, nick, []string{target}, "You're not on that channel", ) @@ -1044,7 +1033,7 @@ func (hdlr *Handlers) handleDirectMsg( if err != nil { hdlr.enqueueNumeric( request.Context(), clientID, - "401", nick, []string{target}, + irc.ErrNoSuchNick, nick, []string{target}, "No such nick/channel", ) hdlr.broker.Notify(sessionID) @@ -1088,7 +1077,7 @@ func (hdlr *Handlers) handleJoin( if target == "" { hdlr.respondIRCError( writer, request, clientID, sessionID, - "461", nick, []string{cmdJoin}, + irc.ErrNeedMoreParams, nick, []string{irc.CmdJoin}, "Not enough parameters", ) @@ -1103,7 +1092,7 @@ func (hdlr *Handlers) handleJoin( if !validChannelRe.MatchString(channel) { hdlr.respondIRCError( writer, request, clientID, sessionID, - "403", nick, []string{channel}, + irc.ErrNoSuchChannel, nick, []string{channel}, "No such channel", ) @@ -1159,7 +1148,7 @@ func (hdlr *Handlers) executeJoin( ) _ = hdlr.fanOutSilent( - request, cmdJoin, nick, channel, nil, memberIDs, + request, irc.CmdJoin, nick, channel, nil, memberIDs, ) hdlr.deliverJoinNumerics( @@ -1210,12 +1199,12 @@ func (hdlr *Handlers) deliverJoinNumerics( if topic != "" { hdlr.enqueueNumeric( - ctx, clientID, "332", nick, + ctx, clientID, irc.RplTopic, nick, []string{channel}, topic, ) } else { hdlr.enqueueNumeric( - ctx, clientID, "331", nick, + ctx, clientID, irc.RplNoTopic, nick, []string{channel}, "No topic is set", ) } @@ -1233,14 +1222,14 @@ func (hdlr *Handlers) deliverJoinNumerics( } hdlr.enqueueNumeric( - ctx, clientID, "353", nick, + ctx, clientID, irc.RplNamReply, nick, []string{"=", channel}, strings.Join(nicks, " "), ) } hdlr.enqueueNumeric( - ctx, clientID, "366", nick, + ctx, clientID, irc.RplEndOfNames, nick, []string{channel}, "End of /NAMES list", ) @@ -1257,7 +1246,7 @@ func (hdlr *Handlers) handlePart( if target == "" { hdlr.enqueueNumeric( request.Context(), clientID, - "461", nick, []string{cmdPart}, + irc.ErrNeedMoreParams, nick, []string{irc.CmdPart}, "Not enough parameters", ) hdlr.broker.Notify(sessionID) @@ -1279,7 +1268,7 @@ func (hdlr *Handlers) handlePart( if err != nil { hdlr.enqueueNumeric( request.Context(), clientID, - "403", nick, []string{channel}, + irc.ErrNoSuchChannel, nick, []string{channel}, "No such channel", ) hdlr.broker.Notify(sessionID) @@ -1295,7 +1284,7 @@ func (hdlr *Handlers) handlePart( ) _ = hdlr.fanOutSilent( - request, cmdPart, nick, channel, body, memberIDs, + request, irc.CmdPart, nick, channel, body, memberIDs, ) err = hdlr.params.Database.PartChannel( @@ -1337,7 +1326,7 @@ func (hdlr *Handlers) handleNick( if len(lines) == 0 { hdlr.respondIRCError( writer, request, clientID, sessionID, - "461", nick, []string{cmdNick}, + irc.ErrNeedMoreParams, nick, []string{irc.CmdNick}, "Not enough parameters", ) @@ -1349,7 +1338,7 @@ func (hdlr *Handlers) handleNick( if !validNickRe.MatchString(newNick) { hdlr.respondIRCError( writer, request, clientID, sessionID, - "432", nick, []string{newNick}, + irc.ErrErroneusNickname, nick, []string{newNick}, "Erroneous nickname", ) @@ -1385,7 +1374,7 @@ func (hdlr *Handlers) executeNickChange( if strings.Contains(err.Error(), "UNIQUE") { hdlr.respondIRCError( writer, request, clientID, sessionID, - "433", nick, []string{newNick}, + irc.ErrNicknameInUse, nick, []string{newNick}, "Nickname is already in use", ) @@ -1435,7 +1424,7 @@ func (hdlr *Handlers) broadcastNick( } dbID, _, _ := hdlr.params.Database.InsertMessage( - request.Context(), cmdNick, oldNick, "", + request.Context(), irc.CmdNick, oldNick, "", nil, json.RawMessage(nickBody), nil, ) @@ -1476,7 +1465,7 @@ func (hdlr *Handlers) handleTopic( if target == "" { hdlr.respondIRCError( writer, request, clientID, sessionID, - "461", nick, []string{cmdTopic}, + irc.ErrNeedMoreParams, nick, []string{irc.CmdTopic}, "Not enough parameters", ) @@ -1487,7 +1476,7 @@ func (hdlr *Handlers) handleTopic( if len(lines) == 0 { hdlr.respondIRCError( writer, request, clientID, sessionID, - "461", nick, []string{cmdTopic}, + irc.ErrNeedMoreParams, nick, []string{irc.CmdTopic}, "Not enough parameters", ) @@ -1505,7 +1494,7 @@ func (hdlr *Handlers) handleTopic( if err != nil { hdlr.respondIRCError( writer, request, clientID, sessionID, - "403", nick, []string{channel}, + irc.ErrNoSuchChannel, nick, []string{channel}, "No such channel", ) @@ -1549,12 +1538,12 @@ func (hdlr *Handlers) executeTopic( ) _ = hdlr.fanOutSilent( - request, cmdTopic, nick, channel, body, memberIDs, + request, irc.CmdTopic, nick, channel, body, memberIDs, ) hdlr.enqueueNumeric( request.Context(), clientID, - "332", nick, []string{channel}, topic, + irc.RplTopic, nick, []string{channel}, topic, ) hdlr.broker.Notify(sessionID) @@ -1566,8 +1555,7 @@ func (hdlr *Handlers) executeTopic( } // dispatchInfoCommand handles informational IRC commands -// that produce server-side numerics (MOTD, LIST, WHO, -// WHOIS, PING). +// that produce server-side numerics (MOTD, PING). func (hdlr *Handlers) dispatchInfoCommand( writer http.ResponseWriter, request *http.Request, @@ -1575,31 +1563,20 @@ func (hdlr *Handlers) dispatchInfoCommand( nick, command, target string, bodyLines func() []string, ) { + _ = target + _ = bodyLines + okResp := map[string]string{"status": "ok"} switch command { - case cmdMotd: + case irc.CmdMotd: hdlr.deliverMOTD( request, clientID, sessionID, nick, ) - case cmdList: - hdlr.handleListCmd( - request, clientID, sessionID, nick, - ) - case cmdWho: - hdlr.handleWhoCmd( - request, clientID, sessionID, nick, - target, - ) - case cmdWhois: - hdlr.handleWhoisCmd( - request, clientID, sessionID, nick, - target, bodyLines, - ) - case cmdPing: + case irc.CmdPing: hdlr.respondJSON(writer, request, map[string]string{ - "command": cmdPong, + "command": irc.CmdPong, "from": hdlr.serverName(), }, http.StatusOK) @@ -1612,222 +1589,6 @@ func (hdlr *Handlers) dispatchInfoCommand( ) } -// handleListCmd sends RPL_LIST (322) for each channel, -// then sends 323 to signal the end of the list. -func (hdlr *Handlers) handleListCmd( - request *http.Request, - clientID, sessionID int64, - nick string, -) { - ctx := request.Context() - - channels, err := hdlr.params.Database.ListAllChannels( - ctx, - ) - if err != nil { - hdlr.enqueueNumeric( - ctx, clientID, "323", nick, nil, - "End of /LIST", - ) - hdlr.broker.Notify(sessionID) - - return - } - - for _, channel := range channels { - memberIDs, _ := - hdlr.params.Database.GetChannelMemberIDs( - ctx, channel.ID, - ) - - count := strconv.Itoa(len(memberIDs)) - topic := channel.Topic - - if topic == "" { - topic = " " - } - - hdlr.enqueueNumeric( - ctx, clientID, "322", nick, - []string{channel.Name, count}, topic, - ) - } - - hdlr.enqueueNumeric( - ctx, clientID, "323", nick, nil, - "End of /LIST", - ) - hdlr.broker.Notify(sessionID) -} - -// handleWhoCmd sends RPL_WHOREPLY (352) for each member -// of the target channel, followed by RPL_ENDOFWHO (315). -func (hdlr *Handlers) handleWhoCmd( - request *http.Request, - clientID, sessionID int64, - nick, target string, -) { - ctx := request.Context() - - if target == "" { - hdlr.enqueueNumeric( - ctx, clientID, "461", nick, - []string{cmdWho}, "Not enough parameters", - ) - hdlr.broker.Notify(sessionID) - - return - } - - channel := target - if !strings.HasPrefix(channel, "#") { - channel = "#" + channel - } - - chID, err := hdlr.params.Database.GetChannelByName( - ctx, channel, - ) - if err != nil { - hdlr.enqueueNumeric( - ctx, clientID, "315", nick, - []string{channel}, "End of /WHO list", - ) - hdlr.broker.Notify(sessionID) - - return - } - - members, err := hdlr.params.Database.ChannelMembers( - ctx, chID, - ) - if err == nil { - srvName := hdlr.serverName() - - for _, mem := range members { - hdlr.enqueueNumeric( - ctx, clientID, "352", nick, - []string{ - channel, mem.Nick, "neoirc", - srvName, mem.Nick, "H", - }, - "0 "+mem.Nick, - ) - } - } - - hdlr.enqueueNumeric( - ctx, clientID, "315", nick, - []string{channel}, "End of /WHO list", - ) - hdlr.broker.Notify(sessionID) -} - -// handleWhoisCmd sends WHOIS reply numerics (311, 312, -// 319, 318) for the target nick. -func (hdlr *Handlers) handleWhoisCmd( - request *http.Request, - clientID, sessionID int64, - nick, target string, - bodyLines func() []string, -) { - ctx := request.Context() - - whoisNick := target - if whoisNick == "" { - lines := bodyLines() - if len(lines) > 0 { - whoisNick = strings.TrimSpace(lines[0]) - } - } - - if whoisNick == "" { - hdlr.enqueueNumeric( - ctx, clientID, "461", nick, - []string{cmdWhois}, "Not enough parameters", - ) - hdlr.broker.Notify(sessionID) - - return - } - - targetSID, err := - hdlr.params.Database.GetSessionByNick( - ctx, whoisNick, - ) - if err != nil { - hdlr.enqueueNumeric( - ctx, clientID, "401", nick, - []string{whoisNick}, - "No such nick/channel", - ) - hdlr.enqueueNumeric( - ctx, clientID, "318", nick, - []string{whoisNick}, - "End of /WHOIS list", - ) - hdlr.broker.Notify(sessionID) - - return - } - - hdlr.sendWhoisNumerics( - ctx, clientID, sessionID, nick, - whoisNick, targetSID, - ) -} - -// sendWhoisNumerics emits 311/312/319/318 for a -// resolved WHOIS target. -func (hdlr *Handlers) sendWhoisNumerics( - ctx context.Context, - clientID, sessionID int64, - nick, whoisNick string, - targetSID int64, -) { - srvName := hdlr.serverName() - - // 311 RPL_WHOISUSER - hdlr.enqueueNumeric( - ctx, clientID, "311", nick, - []string{whoisNick, whoisNick, "neoirc", "*"}, - whoisNick, - ) - - // 312 RPL_WHOISSERVER - hdlr.enqueueNumeric( - ctx, clientID, "312", nick, - []string{whoisNick, srvName}, - srvName, - ) - - // 319 RPL_WHOISCHANNELS - channels, _ := hdlr.params.Database.GetSessionChannels( - ctx, targetSID, - ) - - if len(channels) > 0 { - names := make([]string, 0, len(channels)) - - for _, chanInfo := range channels { - names = append(names, chanInfo.Name) - } - - hdlr.enqueueNumeric( - ctx, clientID, "319", nick, - []string{whoisNick}, - strings.Join(names, " "), - ) - } - - // 318 RPL_ENDOFWHOIS - hdlr.enqueueNumeric( - ctx, clientID, "318", nick, - []string{whoisNick}, - "End of /WHOIS list", - ) - hdlr.broker.Notify(sessionID) -} - func (hdlr *Handlers) handleQuit( writer http.ResponseWriter, request *http.Request, @@ -1846,7 +1607,7 @@ func (hdlr *Handlers) handleQuit( if len(channels) > 0 { dbID, _, _ = hdlr.params.Database.InsertMessage( - request.Context(), cmdQuit, nick, "", + request.Context(), irc.CmdQuit, nick, "", nil, body, nil, ) } @@ -1899,7 +1660,7 @@ func (hdlr *Handlers) handleMode( if target == "" { hdlr.respondIRCError( writer, request, clientID, sessionID, - "461", nick, []string{cmdMode}, + irc.ErrNeedMoreParams, nick, []string{irc.CmdMode}, "Not enough parameters", ) @@ -1911,7 +1672,7 @@ func (hdlr *Handlers) handleMode( // User mode query — return empty modes. hdlr.enqueueNumeric( request.Context(), clientID, - "221", nick, nil, "+", + irc.RplUmodeIs, nick, nil, "+", ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, @@ -1943,7 +1704,7 @@ func (hdlr *Handlers) handleChannelMode( if err != nil { hdlr.respondIRCError( writer, request, clientID, sessionID, - "403", nick, []string{channel}, + irc.ErrNoSuchChannel, nick, []string{channel}, "No such channel", ) @@ -1952,7 +1713,7 @@ func (hdlr *Handlers) handleChannelMode( // 324 RPL_CHANNELMODEIS hdlr.enqueueNumeric( - ctx, clientID, "324", nick, + ctx, clientID, irc.RplChannelModeIs, nick, []string{channel, "+n"}, "", ) @@ -1961,7 +1722,7 @@ func (hdlr *Handlers) handleChannelMode( GetChannelCreatedAt(ctx, chID) if timeErr == nil { hdlr.enqueueNumeric( - ctx, clientID, "329", nick, + ctx, clientID, irc.RplCreationTime, nick, []string{ channel, strconv.FormatInt( @@ -1988,7 +1749,7 @@ func (hdlr *Handlers) handleNames( if target == "" { hdlr.respondIRCError( writer, request, clientID, sessionID, - "461", nick, []string{cmdNames}, + irc.ErrNeedMoreParams, nick, []string{irc.CmdNames}, "Not enough parameters", ) @@ -2008,7 +1769,7 @@ func (hdlr *Handlers) handleNames( if err != nil { hdlr.respondIRCError( writer, request, clientID, sessionID, - "403", nick, []string{channel}, + irc.ErrNoSuchChannel, nick, []string{channel}, "No such channel", ) @@ -2026,14 +1787,14 @@ func (hdlr *Handlers) handleNames( } hdlr.enqueueNumeric( - ctx, clientID, "353", nick, + ctx, clientID, irc.RplNamReply, nick, []string{"=", channel}, strings.Join(nicks, " "), ) } hdlr.enqueueNumeric( - ctx, clientID, "366", nick, + ctx, clientID, irc.RplEndOfNames, nick, []string{channel}, "End of /NAMES list", ) @@ -2071,7 +1832,7 @@ func (hdlr *Handlers) handleList( for _, chanInfo := range channels { // 322 RPL_LIST hdlr.enqueueNumeric( - ctx, clientID, "322", nick, + ctx, clientID, irc.RplList, nick, []string{ chanInfo.Name, strconv.FormatInt( @@ -2084,7 +1845,7 @@ func (hdlr *Handlers) handleList( // 323 — end of channel list. hdlr.enqueueNumeric( - ctx, clientID, "323", nick, nil, + ctx, clientID, irc.RplListEnd, nick, nil, "End of /LIST", ) @@ -2115,7 +1876,7 @@ func (hdlr *Handlers) handleWhois( if queryNick == "" { hdlr.respondIRCError( writer, request, clientID, sessionID, - "461", nick, []string{cmdWhois}, + irc.ErrNeedMoreParams, nick, []string{irc.CmdWhois}, "Not enough parameters", ) @@ -2142,12 +1903,12 @@ func (hdlr *Handlers) executeWhois( ) if err != nil { hdlr.enqueueNumeric( - ctx, clientID, "401", nick, + ctx, clientID, irc.ErrNoSuchNick, nick, []string{queryNick}, "No such nick/channel", ) hdlr.enqueueNumeric( - ctx, clientID, "318", nick, + ctx, clientID, irc.RplEndOfWhois, nick, []string{queryNick}, "End of /WHOIS list", ) @@ -2161,14 +1922,14 @@ func (hdlr *Handlers) executeWhois( // 311 RPL_WHOISUSER hdlr.enqueueNumeric( - ctx, clientID, "311", nick, + ctx, clientID, irc.RplWhoisUser, nick, []string{queryNick, queryNick, srvName, "*"}, queryNick, ) // 312 RPL_WHOISSERVER hdlr.enqueueNumeric( - ctx, clientID, "312", nick, + ctx, clientID, irc.RplWhoisServer, nick, []string{queryNick, srvName}, "neoirc server", ) @@ -2180,7 +1941,7 @@ func (hdlr *Handlers) executeWhois( // 318 RPL_ENDOFWHOIS hdlr.enqueueNumeric( - ctx, clientID, "318", nick, + ctx, clientID, irc.RplEndOfWhois, nick, []string{queryNick}, "End of /WHOIS list", ) @@ -2210,7 +1971,7 @@ func (hdlr *Handlers) deliverWhoisChannels( } hdlr.enqueueNumeric( - ctx, clientID, "319", nick, + ctx, clientID, irc.RplWhoisChannels, nick, []string{queryNick}, strings.Join(chanNames, " "), ) @@ -2226,7 +1987,7 @@ func (hdlr *Handlers) handleWho( if target == "" { hdlr.respondIRCError( writer, request, clientID, sessionID, - "461", nick, []string{cmdWho}, + irc.ErrNeedMoreParams, nick, []string{irc.CmdWho}, "Not enough parameters", ) @@ -2247,7 +2008,7 @@ func (hdlr *Handlers) handleWho( if err != nil { // 315 RPL_ENDOFWHO (empty result) hdlr.enqueueNumeric( - ctx, clientID, "315", nick, + ctx, clientID, irc.RplEndOfWho, nick, []string{target}, "End of /WHO list", ) @@ -2266,7 +2027,7 @@ func (hdlr *Handlers) handleWho( for _, mem := range members { // 352 RPL_WHOREPLY hdlr.enqueueNumeric( - ctx, clientID, "352", nick, + ctx, clientID, irc.RplWhoReply, nick, []string{ channel, mem.Nick, srvName, srvName, mem.Nick, "H", @@ -2278,7 +2039,7 @@ func (hdlr *Handlers) handleWho( // 315 RPL_ENDOFWHO hdlr.enqueueNumeric( - ctx, clientID, "315", nick, + ctx, clientID, irc.RplEndOfWho, nick, []string{channel}, "End of /WHO list", ) @@ -2514,7 +2275,7 @@ func (hdlr *Handlers) cleanupUser( if len(channels) > 0 { quitDBID, _, _ = hdlr.params.Database.InsertMessage( - ctx, cmdQuit, nick, "", + ctx, irc.CmdQuit, nick, "", nil, nil, nil, ) } diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index 8541c89..d8eb7c8 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -12,6 +12,7 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "strconv" "strings" "sync" "testing" @@ -467,8 +468,11 @@ func findNumeric( msgs []map[string]any, numeric string, ) bool { + want, _ := strconv.Atoi(numeric) + for _, msg := range msgs { - if msg[commandKey] == numeric { + code, ok := msg["code"].(float64) + if ok && int(code) == want { return true } } diff --git a/internal/irc/commands.go b/internal/irc/commands.go new file mode 100644 index 0000000..1446cff --- /dev/null +++ b/internal/irc/commands.go @@ -0,0 +1,21 @@ +package irc + +// IRC command names (RFC 1459 / RFC 2812). +const ( + CmdJoin = "JOIN" + CmdList = "LIST" + CmdLusers = "LUSERS" + CmdMode = "MODE" + CmdMotd = "MOTD" + CmdNames = "NAMES" + CmdNick = "NICK" + CmdNotice = "NOTICE" + CmdPart = "PART" + CmdPing = "PING" + CmdPong = "PONG" + CmdPrivmsg = "PRIVMSG" + CmdQuit = "QUIT" + CmdTopic = "TOPIC" + CmdWho = "WHO" + CmdWhois = "WHOIS" +) diff --git a/internal/irc/numerics.go b/internal/irc/numerics.go new file mode 100644 index 0000000..510925b --- /dev/null +++ b/internal/irc/numerics.go @@ -0,0 +1,150 @@ +// Package irc provides constants and utilities for the +// IRC protocol, including numeric reply codes from +// RFC 1459 and RFC 2812, and standard command names. +package irc + +// Connection registration replies (001-005). +const ( + RplWelcome = 1 + RplYourHost = 2 + RplCreated = 3 + RplMyInfo = 4 + RplIsupport = 5 +) + +// Command responses (200-399). +const ( + RplUmodeIs = 221 + RplLuserClient = 251 + RplLuserOp = 252 + RplLuserUnknown = 253 + RplLuserChannels = 254 + RplLuserMe = 255 + RplAway = 301 + RplUserHost = 302 + RplIson = 303 + RplUnaway = 305 + RplNowAway = 306 + RplWhoisUser = 311 + RplWhoisServer = 312 + RplWhoisOperator = 313 + RplEndOfWho = 315 + RplWhoisIdle = 317 + RplEndOfWhois = 318 + RplWhoisChannels = 319 + RplList = 322 + RplListEnd = 323 + RplChannelModeIs = 324 + RplCreationTime = 329 + RplNoTopic = 331 + RplTopic = 332 + RplTopicWhoTime = 333 + RplInviting = 341 + RplWhoReply = 352 + RplNamReply = 353 + RplEndOfNames = 366 + RplBanList = 367 + RplEndOfBanList = 368 + RplMotd = 372 + RplMotdStart = 375 + RplEndOfMotd = 376 +) + +// Error replies (400-599). +const ( + ErrNoSuchNick = 401 + ErrNoSuchServer = 402 + ErrNoSuchChannel = 403 + ErrCannotSendToChan = 404 + ErrTooManyChannels = 405 + ErrNoRecipient = 411 + ErrNoTextToSend = 412 + ErrUnknownCommand = 421 + ErrNoNicknameGiven = 431 + ErrErroneusNickname = 432 + ErrNicknameInUse = 433 + ErrUserNotInChannel = 441 + ErrNotOnChannel = 442 + ErrNotRegistered = 451 + ErrNeedMoreParams = 461 + ErrAlreadyRegistered = 462 + ErrChannelIsFull = 471 + ErrInviteOnlyChan = 473 + ErrBannedFromChan = 474 + ErrBadChannelKey = 475 + ErrChanOpPrivsNeeded = 482 +) + +// names maps numeric codes to their standard IRC names. +// +//nolint:gochecknoglobals +var names = map[int]string{ + RplWelcome: "RPL_WELCOME", + RplYourHost: "RPL_YOURHOST", + RplCreated: "RPL_CREATED", + RplMyInfo: "RPL_MYINFO", + RplIsupport: "RPL_ISUPPORT", + RplUmodeIs: "RPL_UMODEIS", + RplLuserClient: "RPL_LUSERCLIENT", + RplLuserOp: "RPL_LUSEROP", + RplLuserUnknown: "RPL_LUSERUNKNOWN", + RplLuserChannels: "RPL_LUSERCHANNELS", + RplLuserMe: "RPL_LUSERME", + RplAway: "RPL_AWAY", + RplUserHost: "RPL_USERHOST", + RplIson: "RPL_ISON", + RplUnaway: "RPL_UNAWAY", + RplNowAway: "RPL_NOWAWAY", + RplWhoisUser: "RPL_WHOISUSER", + RplWhoisServer: "RPL_WHOISSERVER", + RplWhoisOperator: "RPL_WHOISOPERATOR", + RplEndOfWho: "RPL_ENDOFWHO", + RplWhoisIdle: "RPL_WHOISIDLE", + RplEndOfWhois: "RPL_ENDOFWHOIS", + RplWhoisChannels: "RPL_WHOISCHANNELS", + RplList: "RPL_LIST", + RplListEnd: "RPL_LISTEND", //nolint:misspell + RplChannelModeIs: "RPL_CHANNELMODEIS", + RplCreationTime: "RPL_CREATIONTIME", + RplNoTopic: "RPL_NOTOPIC", + RplTopic: "RPL_TOPIC", + RplTopicWhoTime: "RPL_TOPICWHOTIME", + RplInviting: "RPL_INVITING", + RplWhoReply: "RPL_WHOREPLY", + RplNamReply: "RPL_NAMREPLY", + RplEndOfNames: "RPL_ENDOFNAMES", + RplBanList: "RPL_BANLIST", + RplEndOfBanList: "RPL_ENDOFBANLIST", + RplMotd: "RPL_MOTD", + RplMotdStart: "RPL_MOTDSTART", + RplEndOfMotd: "RPL_ENDOFMOTD", + + ErrNoSuchNick: "ERR_NOSUCHNICK", + ErrNoSuchServer: "ERR_NOSUCHSERVER", + ErrNoSuchChannel: "ERR_NOSUCHCHANNEL", + ErrCannotSendToChan: "ERR_CANNOTSENDTOCHAN", + ErrTooManyChannels: "ERR_TOOMANYCHANNELS", + ErrNoRecipient: "ERR_NORECIPIENT", + ErrNoTextToSend: "ERR_NOTEXTTOSEND", + ErrUnknownCommand: "ERR_UNKNOWNCOMMAND", + ErrNoNicknameGiven: "ERR_NONICKNAMEGIVEN", + ErrErroneusNickname: "ERR_ERRONEUSNICKNAME", + ErrNicknameInUse: "ERR_NICKNAMEINUSE", + ErrUserNotInChannel: "ERR_USERNOTINCHANNEL", + ErrNotOnChannel: "ERR_NOTONCHANNEL", + ErrNotRegistered: "ERR_NOTREGISTERED", + ErrNeedMoreParams: "ERR_NEEDMOREPARAMS", + ErrAlreadyRegistered: "ERR_ALREADYREGISTERED", + ErrChannelIsFull: "ERR_CHANNELISFULL", + ErrInviteOnlyChan: "ERR_INVITEONLYCHAN", + ErrBannedFromChan: "ERR_BANNEDFROMCHAN", + ErrBadChannelKey: "ERR_BADCHANNELKEY", + ErrChanOpPrivsNeeded: "ERR_CHANOPRIVSNEEDED", +} + +// Name returns the standard IRC name for a numeric code +// (e.g., Name(2) returns "RPL_YOURHOST"). Returns an +// empty string if the code is unknown. +func Name(code int) string { + return names[code] +} -- 2.49.1