From 7bbd6de73aa08794358096cf5b3a8800012f16fd Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 9 Mar 2026 15:49:29 -0700 Subject: [PATCH] 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] +}