From df82c6b302067cc77afc5f0e900f1359fcf321c9 Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 9 Mar 2026 14:53:49 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20IRC=20numerics=20batch=202?= =?UTF-8?q?=20=E2=80=94=20connection=20registration,=20channel=20ops,=20us?= =?UTF-8?q?er=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 092428a..4961a57 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 @@ -677,6 +788,55 @@ func (hdlr *Handlers) dispatchCommand( "from": hdlr.serverName(), }, http.StatusOK) + 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, @@ -1450,6 +1610,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(), } }