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/cmd/neoirc-cli/api/client.go b/cmd/neoirc-cli/api/client.go index 98eea62..8f7fdcf 100644 --- a/cmd/neoirc-cli/api/client.go +++ b/cmd/neoirc-cli/api/client.go @@ -13,6 +13,8 @@ import ( "strconv" "strings" "time" + + "git.eeqj.de/sneak/neoirc/internal/irc" ) const ( @@ -168,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: "JOIN", To: channel, + Command: irc.CmdJoin, To: channel, }, ) } @@ -177,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: "PART", To: channel, + Command: irc.CmdPart, To: channel, }, ) } diff --git a/cmd/neoirc-cli/main.go b/cmd/neoirc-cli/main.go index 2b81705..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 ( @@ -86,7 +87,7 @@ func (a *App) handleInput(text string) { } err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct - Command: "PRIVMSG", + Command: irc.CmdPrivmsg, To: target, Body: []string{text}, }) @@ -241,7 +242,7 @@ func (a *App) cmdNick(nick string) { } err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct - Command: "NICK", + Command: irc.CmdNick, Body: []string{nick}, }) if err != nil { @@ -376,7 +377,7 @@ func (a *App) cmdMsg(args string) { } err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct - Command: "PRIVMSG", + Command: irc.CmdPrivmsg, To: target, Body: []string{text}, }) @@ -434,7 +435,7 @@ func (a *App) cmdTopic(args string) { if args == "" { err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct - Command: "TOPIC", + Command: irc.CmdTopic, To: target, }) if err != nil { @@ -447,7 +448,7 @@ func (a *App) cmdTopic(args string) { } err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct - Command: "TOPIC", + Command: irc.CmdTopic, To: target, Body: []string{args}, }) @@ -535,7 +536,7 @@ func (a *App) cmdMotd() { } err := a.client.SendMessage( - &api.Message{Command: "MOTD"}, //nolint:exhaustruct + &api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct ) if err != nil { a.ui.AddStatus(fmt.Sprintf( @@ -572,7 +573,7 @@ func (a *App) cmdWho(args string) { err := a.client.SendMessage( &api.Message{ //nolint:exhaustruct - Command: "WHO", To: channel, + Command: irc.CmdWho, To: channel, }, ) if err != nil { @@ -603,7 +604,7 @@ func (a *App) cmdWhois(args string) { err := a.client.SendMessage( &api.Message{ //nolint:exhaustruct - Command: "WHOIS", To: args, + Command: irc.CmdWhois, To: args, }, ) if err != nil { @@ -653,7 +654,7 @@ func (a *App) cmdQuit() { if a.connected && a.client != nil { _ = a.client.SendMessage( - &api.Message{Command: "QUIT"}, //nolint:exhaustruct + &api.Message{Command: irc.CmdQuit}, //nolint:exhaustruct ) } @@ -738,19 +739,19 @@ func (a *App) handleServerMessage(msg *api.Message) { a.mu.Unlock() switch msg.Command { - case "PRIVMSG": + case irc.CmdPrivmsg: a.handlePrivmsgEvent(msg, timestamp, myNick) - case "JOIN": + case irc.CmdJoin: a.handleJoinEvent(msg, timestamp) - case "PART": + case irc.CmdPart: a.handlePartEvent(msg, timestamp) - case "QUIT": + case irc.CmdQuit: a.handleQuitEvent(msg, timestamp) - case "NICK": + case irc.CmdNick: a.handleNickEvent(msg, timestamp, myNick) - case "NOTICE": + case irc.CmdNotice: a.handleNoticeEvent(msg, timestamp) - case "TOPIC": + 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 193f639..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) } @@ -953,3 +974,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..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,7 +28,6 @@ const ( defaultMaxBodySize = 4096 defaultHistLimit = 50 maxHistLimit = 500 - cmdPrivmsg = "PRIVMSG" ) func (hdlr *Handlers) maxBodySize() int64 { @@ -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, + ctx, clientID, irc.RplWelcome, nick, nil, "Welcome to the network, "+nick, ) + + // 002 RPL_YOURHOST + hdlr.enqueueNumeric( + ctx, clientID, irc.RplYourHost, nick, nil, + "Your host is "+srvName+ + ", running version "+version, + ) + + // 003 RPL_CREATED + hdlr.enqueueNumeric( + ctx, clientID, irc.RplCreated, nick, nil, + "This server was created "+ + hdlr.params.Globals.StartTime. + Format("2006-01-02"), + ) + + // 004 RPL_MYINFO + hdlr.enqueueNumeric( + ctx, clientID, irc.RplMyInfo, nick, + []string{srvName, version, "", "imnst"}, + "", + ) + + // 005 RPL_ISUPPORT + hdlr.enqueueNumeric( + ctx, clientID, irc.RplIsupport, 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, irc.RplLuserClient, nick, nil, + fmt.Sprintf( + "There are %d users and 0 invisible on 1 servers", + userCount, + ), + ) + + // 252 RPL_LUSEROP + hdlr.enqueueNumeric( + ctx, clientID, irc.RplLuserOp, nick, + []string{"0"}, + "operator(s) online", + ) + + // 254 RPL_LUSERCHANNELS + hdlr.enqueueNumeric( + ctx, clientID, irc.RplLuserChannels, nick, + []string{strconv.FormatInt(chanCount, 10)}, + "channels formed", + ) + + // 255 RPL_LUSERME + hdlr.enqueueNumeric( + ctx, clientID, irc.RplLuserMe, 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 @@ -255,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.", ) @@ -286,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( @@ -639,47 +753,96 @@ func (hdlr *Handlers) dispatchCommand( bodyLines func() []string, ) { switch command { - case cmdPrivmsg, "NOTICE": + case irc.CmdPrivmsg, irc.CmdNotice: hdlr.handlePrivmsg( writer, request, sessionID, clientID, nick, command, target, body, bodyLines, ) - case "JOIN": + case irc.CmdJoin: hdlr.handleJoin( writer, request, sessionID, clientID, nick, target, ) - case "PART": + case irc.CmdPart: hdlr.handlePart( writer, request, sessionID, clientID, nick, target, body, ) - case "NICK": + case irc.CmdNick: hdlr.handleNick( writer, request, sessionID, clientID, nick, bodyLines, ) - case "TOPIC": + case irc.CmdTopic: hdlr.handleTopic( writer, request, sessionID, clientID, nick, target, body, bodyLines, ) - case "QUIT": + case irc.CmdQuit: hdlr.handleQuit( writer, request, sessionID, nick, body, ) - case "MOTD", "LIST", "WHO", "WHOIS", "PING": + case irc.CmdMotd, irc.CmdPing: hdlr.dispatchInfoCommand( writer, request, 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 irc.CmdMode: + hdlr.handleMode( + writer, request, + sessionID, clientID, nick, + target, bodyLines, + ) + case irc.CmdNames: + hdlr.handleNames( + writer, request, + sessionID, clientID, nick, target, + ) + case irc.CmdList: + hdlr.handleList( + writer, request, + sessionID, clientID, nick, + ) + case irc.CmdWhois: + hdlr.handleWhois( + writer, request, + sessionID, clientID, nick, + target, bodyLines, + ) + case irc.CmdWho: + hdlr.handleWho( + writer, request, + sessionID, clientID, nick, target, + ) + case irc.CmdLusers: + hdlr.handleLusers( + writer, request, + sessionID, clientID, nick, + ) default: hdlr.enqueueNumeric( request.Context(), clientID, - "421", nick, []string{command}, + irc.ErrUnknownCommand, nick, []string{command}, "Unknown command", ) hdlr.broker.Notify(sessionID) @@ -700,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) @@ -715,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) @@ -749,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, @@ -776,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", ) @@ -802,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", ) @@ -869,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) @@ -913,7 +1077,7 @@ func (hdlr *Handlers) handleJoin( if target == "" { hdlr.respondIRCError( writer, request, clientID, sessionID, - "461", nick, []string{"JOIN"}, + irc.ErrNeedMoreParams, nick, []string{irc.CmdJoin}, "Not enough parameters", ) @@ -928,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", ) @@ -984,7 +1148,7 @@ func (hdlr *Handlers) executeJoin( ) _ = hdlr.fanOutSilent( - request, "JOIN", nick, channel, nil, memberIDs, + request, irc.CmdJoin, nick, channel, nil, memberIDs, ) hdlr.deliverJoinNumerics( @@ -1035,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", ) } @@ -1058,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", ) @@ -1082,7 +1246,7 @@ func (hdlr *Handlers) handlePart( if target == "" { hdlr.enqueueNumeric( request.Context(), clientID, - "461", nick, []string{"PART"}, + irc.ErrNeedMoreParams, nick, []string{irc.CmdPart}, "Not enough parameters", ) hdlr.broker.Notify(sessionID) @@ -1104,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) @@ -1120,7 +1284,7 @@ func (hdlr *Handlers) handlePart( ) _ = hdlr.fanOutSilent( - request, "PART", nick, channel, body, memberIDs, + request, irc.CmdPart, nick, channel, body, memberIDs, ) err = hdlr.params.Database.PartChannel( @@ -1162,7 +1326,7 @@ func (hdlr *Handlers) handleNick( if len(lines) == 0 { hdlr.respondIRCError( writer, request, clientID, sessionID, - "461", nick, []string{"NICK"}, + irc.ErrNeedMoreParams, nick, []string{irc.CmdNick}, "Not enough parameters", ) @@ -1174,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", ) @@ -1210,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", ) @@ -1260,7 +1424,7 @@ func (hdlr *Handlers) broadcastNick( } dbID, _, _ := hdlr.params.Database.InsertMessage( - request.Context(), "NICK", oldNick, "", + request.Context(), irc.CmdNick, oldNick, "", nil, json.RawMessage(nickBody), nil, ) @@ -1301,7 +1465,7 @@ func (hdlr *Handlers) handleTopic( if target == "" { hdlr.respondIRCError( writer, request, clientID, sessionID, - "461", nick, []string{"TOPIC"}, + irc.ErrNeedMoreParams, nick, []string{irc.CmdTopic}, "Not enough parameters", ) @@ -1312,7 +1476,7 @@ func (hdlr *Handlers) handleTopic( if len(lines) == 0 { hdlr.respondIRCError( writer, request, clientID, sessionID, - "461", nick, []string{"TOPIC"}, + irc.ErrNeedMoreParams, nick, []string{irc.CmdTopic}, "Not enough parameters", ) @@ -1330,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", ) @@ -1374,12 +1538,12 @@ func (hdlr *Handlers) executeTopic( ) _ = hdlr.fanOutSilent( - request, "TOPIC", 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) @@ -1391,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, @@ -1400,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 "MOTD": + case irc.CmdMotd: hdlr.deliverMOTD( request, clientID, sessionID, nick, ) - case "LIST": - hdlr.handleListCmd( - request, clientID, sessionID, nick, - ) - case "WHO": - hdlr.handleWhoCmd( - request, clientID, sessionID, nick, - target, - ) - case "WHOIS": - hdlr.handleWhoisCmd( - request, clientID, sessionID, nick, - target, bodyLines, - ) - case "PING": + case irc.CmdPing: hdlr.respondJSON(writer, request, map[string]string{ - "command": "PONG", + "command": irc.CmdPong, "from": hdlr.serverName(), }, http.StatusOK) @@ -1437,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{"WHO"}, "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{"WHOIS"}, "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, @@ -1671,7 +1607,7 @@ func (hdlr *Handlers) handleQuit( if len(channels) > 0 { dbID, _, _ = hdlr.params.Database.InsertMessage( - request.Context(), "QUIT", nick, "", + request.Context(), irc.CmdQuit, nick, "", nil, body, nil, ) } @@ -1712,6 +1648,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, + irc.ErrNeedMoreParams, nick, []string{irc.CmdMode}, + "Not enough parameters", + ) + + return + } + + channel := target + if !strings.HasPrefix(channel, "#") { + // User mode query — return empty modes. + hdlr.enqueueNumeric( + request.Context(), clientID, + irc.RplUmodeIs, 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, + irc.ErrNoSuchChannel, nick, []string{channel}, + "No such channel", + ) + + return + } + + // 324 RPL_CHANNELMODEIS + hdlr.enqueueNumeric( + ctx, clientID, irc.RplChannelModeIs, nick, + []string{channel, "+n"}, "", + ) + + // 329 RPL_CREATIONTIME + createdAt, timeErr := hdlr.params.Database. + GetChannelCreatedAt(ctx, chID) + if timeErr == nil { + hdlr.enqueueNumeric( + ctx, clientID, irc.RplCreationTime, 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, + irc.ErrNeedMoreParams, nick, []string{irc.CmdNames}, + "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, + irc.ErrNoSuchChannel, 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, irc.RplNamReply, nick, + []string{"=", channel}, + strings.Join(nicks, " "), + ) + } + + hdlr.enqueueNumeric( + ctx, clientID, irc.RplEndOfNames, 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, irc.RplList, nick, + []string{ + chanInfo.Name, + strconv.FormatInt( + chanInfo.MemberCount, 10, + ), + }, + chanInfo.Topic, + ) + } + + // 323 — end of channel list. + hdlr.enqueueNumeric( + ctx, clientID, irc.RplListEnd, 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, + irc.ErrNeedMoreParams, nick, []string{irc.CmdWhois}, + "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, irc.ErrNoSuchNick, nick, + []string{queryNick}, + "No such nick/channel", + ) + hdlr.enqueueNumeric( + ctx, clientID, irc.RplEndOfWhois, 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, irc.RplWhoisUser, nick, + []string{queryNick, queryNick, srvName, "*"}, + queryNick, + ) + + // 312 RPL_WHOISSERVER + hdlr.enqueueNumeric( + ctx, clientID, irc.RplWhoisServer, nick, + []string{queryNick, srvName}, + "neoirc server", + ) + + // 319 RPL_WHOISCHANNELS + hdlr.deliverWhoisChannels( + ctx, clientID, nick, queryNick, targetSID, + ) + + // 318 RPL_ENDOFWHOIS + hdlr.enqueueNumeric( + ctx, clientID, irc.RplEndOfWhois, 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, irc.RplWhoisChannels, 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, + irc.ErrNeedMoreParams, nick, []string{irc.CmdWho}, + "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, irc.RplEndOfWho, 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, irc.RplWhoReply, nick, + []string{ + channel, mem.Nick, srvName, + srvName, mem.Nick, "H", + }, + "0 "+mem.Nick, + ) + } + } + + // 315 RPL_ENDOFWHO + hdlr.enqueueNumeric( + ctx, clientID, irc.RplEndOfWho, 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( @@ -1921,7 +2275,7 @@ func (hdlr *Handlers) cleanupUser( if len(channels) > 0 { quitDBID, _, _ = hdlr.params.Database.InsertMessage( - ctx, "QUIT", nick, "", + ctx, irc.CmdQuit, nick, "", nil, nil, nil, ) } diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index b19587b..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" @@ -115,8 +116,9 @@ func newTestServer( func newTestGlobals() *globals.Globals { return &globals.Globals{ - Appname: "neoirc-test", - Version: "test", + Appname: "neoirc-test", + Version: "test", + StartTime: time.Now(), } } @@ -466,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] +}