From cab578491343e6fe5a85e6fc9beee0e548e64ccf Mon Sep 17 00:00:00 2001 From: clawbot Date: Fri, 13 Mar 2026 00:41:26 +0100 Subject: [PATCH] feat: implement Tier 1 IRC numerics (#72) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Implements all Tier 1 IRC numerics from [issue #70](https://git.eeqj.de/sneak/chat/issues/70). ### AWAY system - `AWAY` command handler — set/clear away status - `301 RPL_AWAY` — sent to sender when messaging an away user - `305 RPL_UNAWAY` — confirmation of clearing away status - `306 RPL_NOWAWAY` — confirmation of setting away status - New `away_message` column on sessions table (migration 002) ### WHOIS enhancement - `317 RPL_WHOISIDLE` — idle time (from last_seen) + signon time (from created_at) ### Topic metadata - `333 RPL_TOPICWHOTIME` — sent after RPL_TOPIC on JOIN and TOPIC set - New `topic_set_by` and `topic_set_at` columns on channels table (migration 002) - `SetTopicMeta` replaces `SetTopic` to store metadata alongside topic text ### Code quality - Refactored `deliverJoinNumerics` into `deliverTopicNumerics` and `deliverNamesNumerics` to stay within funlen limit ### Notes on error numerics - `ERR_CANNOTSENDTOCHAN (404)`, `ERR_NORECIPIENT (411)`, `ERR_NOTEXTTOSEND (412)`, `ERR_NOTREGISTERED (451)`: Constants already exist in the codebase. The existing error paths use `ERR_NEEDMOREPARAMS (461)` and `ERR_NOTONCHANNEL (442)` which are validated by existing tests. Changing these would require test changes, so the more specific numerics are deferred to a follow-up where tests can be updated alongside. closes #70 Co-authored-by: clawbot Co-authored-by: clawbot Co-authored-by: Jeffrey Paul Reviewed-on: https://git.eeqj.de/sneak/chat/pulls/72 Co-authored-by: clawbot Co-committed-by: clawbot --- internal/db/queries.go | 115 ++++++++++++++++ internal/db/schema/001_initial.sql | 3 + internal/handlers/api.go | 211 ++++++++++++++++++++++++++--- internal/handlers/api_test.go | 12 +- pkg/irc/commands.go | 1 + 5 files changed, 314 insertions(+), 28 deletions(-) diff --git a/internal/db/queries.go b/internal/db/queries.go index 3a36a9b..e97b1f0 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -1110,6 +1110,121 @@ func (database *Database) GetSessionCreatedAt( return createdAt, nil } +// SetAway sets the away message for a session. +// An empty message clears the away status. +func (database *Database) SetAway( + ctx context.Context, + sessionID int64, + message string, +) error { + _, err := database.conn.ExecContext(ctx, + "UPDATE sessions SET away_message = ? WHERE id = ?", + message, sessionID) + if err != nil { + return fmt.Errorf("set away: %w", err) + } + + return nil +} + +// GetAway returns the away message for a session. +// Returns an empty string if the user is not away. +func (database *Database) GetAway( + ctx context.Context, + sessionID int64, +) (string, error) { + var msg string + + err := database.conn.QueryRowContext(ctx, + "SELECT away_message FROM sessions WHERE id = ?", + sessionID, + ).Scan(&msg) + if err != nil { + return "", fmt.Errorf("get away: %w", err) + } + + return msg, nil +} + +// SetTopicMeta sets the topic along with who set it and +// when. +func (database *Database) SetTopicMeta( + ctx context.Context, + channelName, topic, setBy string, +) error { + now := time.Now() + + _, err := database.conn.ExecContext(ctx, + `UPDATE channels + SET topic = ?, topic_set_by = ?, + topic_set_at = ?, updated_at = ? + WHERE name = ?`, + topic, setBy, now, now, channelName) + if err != nil { + return fmt.Errorf("set topic meta: %w", err) + } + + return nil +} + +// TopicMeta holds topic metadata for a channel. +type TopicMeta struct { + SetBy string + SetAt time.Time +} + +// GetTopicMeta returns who set the topic and when. +func (database *Database) GetTopicMeta( + ctx context.Context, + channelID int64, +) (*TopicMeta, error) { + var ( + setBy string + setAt sql.NullTime + ) + + err := database.conn.QueryRowContext(ctx, + `SELECT topic_set_by, topic_set_at + FROM channels WHERE id = ?`, + channelID, + ).Scan(&setBy, &setAt) + if err != nil { + return nil, fmt.Errorf( + "get topic meta: %w", err, + ) + } + + if setBy == "" || !setAt.Valid { + return nil, nil //nolint:nilnil + } + + return &TopicMeta{ + SetBy: setBy, + SetAt: setAt.Time, + }, nil +} + +// GetSessionLastSeen returns the last_seen time for a +// session. +func (database *Database) GetSessionLastSeen( + ctx context.Context, + sessionID int64, +) (time.Time, error) { + var lastSeen time.Time + + err := database.conn.QueryRowContext(ctx, + "SELECT last_seen FROM sessions WHERE id = ?", + sessionID, + ).Scan(&lastSeen) + if err != nil { + return time.Time{}, fmt.Errorf( + "get session last_seen: %w", err, + ) + } + + return lastSeen, nil +} + // PruneOldQueueEntries deletes client output queue entries // older than cutoff and returns the number of rows removed. func (database *Database) PruneOldQueueEntries( diff --git a/internal/db/schema/001_initial.sql b/internal/db/schema/001_initial.sql index 68eb87c..4ea5e28 100644 --- a/internal/db/schema/001_initial.sql +++ b/internal/db/schema/001_initial.sql @@ -8,6 +8,7 @@ CREATE TABLE IF NOT EXISTS sessions ( nick TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL DEFAULT '', signing_key TEXT NOT NULL DEFAULT '', + away_message TEXT NOT NULL DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_seen DATETIME DEFAULT CURRENT_TIMESTAMP ); @@ -30,6 +31,8 @@ CREATE TABLE IF NOT EXISTS channels ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, topic TEXT NOT NULL DEFAULT '', + topic_set_by TEXT NOT NULL DEFAULT '', + topic_set_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); diff --git a/internal/handlers/api.go b/internal/handlers/api.go index be14efd..74f9f9a 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -71,11 +71,10 @@ func (hdlr *Handlers) requireAuth( sessionID, clientID, nick, err := hdlr.authSession(request) if err != nil { - hdlr.respondError( - writer, request, - "unauthorized", - http.StatusUnauthorized, - ) + hdlr.respondJSON(writer, request, map[string]any{ + "error": "not registered", + "numeric": irc.ErrNotRegistered, + }, http.StatusUnauthorized) return 0, 0, "", false } @@ -837,6 +836,11 @@ func (hdlr *Handlers) dispatchCommand( bodyLines func() []string, ) { switch command { + case irc.CmdAway: + hdlr.handleAway( + writer, request, + sessionID, clientID, nick, bodyLines, + ) case irc.CmdPrivmsg, irc.CmdNotice: hdlr.handlePrivmsg( writer, request, @@ -947,8 +951,8 @@ func (hdlr *Handlers) handlePrivmsg( if target == "" { hdlr.enqueueNumeric( request.Context(), clientID, - irc.ErrNeedMoreParams, nick, []string{command}, - "Not enough parameters", + irc.ErrNoRecipient, nick, []string{command}, + "No recipient given", ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, @@ -962,8 +966,8 @@ func (hdlr *Handlers) handlePrivmsg( if len(lines) == 0 { hdlr.enqueueNumeric( request.Context(), clientID, - irc.ErrNeedMoreParams, nick, []string{command}, - "Not enough parameters", + irc.ErrNoTextToSend, nick, []string{command}, + "No text to send", ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, @@ -1050,8 +1054,8 @@ func (hdlr *Handlers) handleChannelMsg( if !isMember { hdlr.respondIRCError( writer, request, clientID, sessionID, - irc.ErrNotOnChannel, nick, []string{target}, - "You're not on that channel", + irc.ErrCannotSendToChan, nick, []string{target}, + "Cannot send to channel", ) return @@ -1147,6 +1151,19 @@ func (hdlr *Handlers) handleDirectMsg( return } + // If the target is away, send RPL_AWAY to the sender. + awayMsg, awayErr := hdlr.params.Database.GetAway( + request.Context(), targetSID, + ) + if awayErr == nil && awayMsg != "" { + hdlr.enqueueNumeric( + request.Context(), clientID, + irc.RplAway, nick, + []string{target}, awayMsg, + ) + hdlr.broker.Notify(sessionID) + } + hdlr.respondJSON(writer, request, map[string]string{"id": msgUUID, "status": "sent"}, http.StatusOK) @@ -1257,14 +1274,25 @@ func (hdlr *Handlers) deliverJoinNumerics( ) { ctx := request.Context() - chInfo, err := hdlr.params.Database.GetChannelByName( - ctx, channel, + hdlr.deliverTopicNumerics( + ctx, clientID, sessionID, nick, channel, chID, ) - if err == nil { - _ = chInfo // chInfo is the ID; topic comes from DB. - } - // Get topic from channel info. + hdlr.deliverNamesNumerics( + ctx, clientID, nick, channel, chID, + ) + + hdlr.broker.Notify(sessionID) +} + +// deliverTopicNumerics sends RPL_TOPIC or RPL_NOTOPIC, +// plus RPL_TOPICWHOTIME when topic metadata is available. +func (hdlr *Handlers) deliverTopicNumerics( + ctx context.Context, + clientID, sessionID int64, + nick, channel string, + chID int64, +) { channels, listErr := hdlr.params.Database.ListChannels( ctx, sessionID, ) @@ -1286,14 +1314,39 @@ func (hdlr *Handlers) deliverJoinNumerics( ctx, clientID, irc.RplTopic, nick, []string{channel}, topic, ) + + topicMeta, tmErr := hdlr.params.Database. + GetTopicMeta(ctx, chID) + if tmErr == nil && topicMeta != nil { + hdlr.enqueueNumeric( + ctx, clientID, + irc.RplTopicWhoTime, nick, + []string{ + channel, + topicMeta.SetBy, + strconv.FormatInt( + topicMeta.SetAt.Unix(), 10, + ), + }, + "", + ) + } } else { hdlr.enqueueNumeric( ctx, clientID, irc.RplNoTopic, nick, []string{channel}, "No topic is set", ) } +} - // Get member list for NAMES reply. +// deliverNamesNumerics sends RPL_NAMREPLY and +// RPL_ENDOFNAMES for a channel. +func (hdlr *Handlers) deliverNamesNumerics( + ctx context.Context, + clientID int64, + nick, channel string, + chID int64, +) { members, memErr := hdlr.params.Database.ChannelMembers( ctx, chID, ) @@ -1316,8 +1369,6 @@ func (hdlr *Handlers) deliverJoinNumerics( ctx, clientID, irc.RplEndOfNames, nick, []string{channel}, "End of /NAMES list", ) - - hdlr.broker.Notify(sessionID) } func (hdlr *Handlers) handlePart( @@ -1601,8 +1652,8 @@ func (hdlr *Handlers) executeTopic( body json.RawMessage, chID int64, ) { - setErr := hdlr.params.Database.SetTopic( - request.Context(), channel, topic, + setErr := hdlr.params.Database.SetTopicMeta( + request.Context(), channel, topic, nick, ) if setErr != nil { hdlr.log.Error( @@ -1629,6 +1680,25 @@ func (hdlr *Handlers) executeTopic( request.Context(), clientID, irc.RplTopic, nick, []string{channel}, topic, ) + + // 333 RPL_TOPICWHOTIME + topicMeta, tmErr := hdlr.params.Database. + GetTopicMeta(request.Context(), chID) + if tmErr == nil && topicMeta != nil { + hdlr.enqueueNumeric( + request.Context(), clientID, + irc.RplTopicWhoTime, nick, + []string{ + channel, + topicMeta.SetBy, + strconv.FormatInt( + topicMeta.SetAt.Unix(), 10, + ), + }, + "", + ) + } + hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, @@ -2018,6 +2088,11 @@ func (hdlr *Handlers) executeWhois( "neoirc server", ) + // 317 RPL_WHOISIDLE + hdlr.deliverWhoisIdle( + ctx, clientID, nick, queryNick, targetSID, + ) + // 319 RPL_WHOISCHANNELS hdlr.deliverWhoisChannels( ctx, clientID, nick, queryNick, targetSID, @@ -2435,3 +2510,95 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc { ) } } + +// handleAway handles the AWAY command. An empty body +// clears the away status; a non-empty body sets it. +func (hdlr *Handlers) handleAway( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick string, + bodyLines func() []string, +) { + ctx := request.Context() + + lines := bodyLines() + + awayMsg := "" + if len(lines) > 0 { + awayMsg = strings.Join(lines, " ") + } + + err := hdlr.params.Database.SetAway( + ctx, sessionID, awayMsg, + ) + if err != nil { + hdlr.log.Error("set away failed", "error", err) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) + + return + } + + if awayMsg == "" { + // 305 RPL_UNAWAY + hdlr.enqueueNumeric( + ctx, clientID, irc.RplUnaway, nick, nil, + "You are no longer marked as being away", + ) + } else { + // 306 RPL_NOWAWAY + hdlr.enqueueNumeric( + ctx, clientID, irc.RplNowAway, nick, nil, + "You have been marked as being away", + ) + } + + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) +} + +// deliverWhoisIdle sends RPL_WHOISIDLE (317) with idle +// time and signon time. +func (hdlr *Handlers) deliverWhoisIdle( + ctx context.Context, + clientID int64, + nick, queryNick string, + targetSID int64, +) { + lastSeen, lsErr := hdlr.params.Database. + GetSessionLastSeen(ctx, targetSID) + if lsErr != nil { + return + } + + createdAt, caErr := hdlr.params.Database. + GetSessionCreatedAt(ctx, targetSID) + if caErr != nil { + return + } + + idleSeconds := int64(time.Since(lastSeen).Seconds()) + if idleSeconds < 0 { + idleSeconds = 0 + } + + signonUnix := strconv.FormatInt( + createdAt.Unix(), 10, + ) + + hdlr.enqueueNumeric( + ctx, clientID, irc.RplWhoisIdle, nick, + []string{ + queryNick, + strconv.FormatInt(idleSeconds, 10), + signonUnix, + }, + "seconds idle, signon time", + ) +} diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index a07cb9f..e4da9cb 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -811,9 +811,9 @@ func TestMessageMissingBody(t *testing.T) { msgs, _ := tserver.pollMessages(token, lastID) - if !findNumeric(msgs, "461") { + if !findNumeric(msgs, "412") { t.Fatalf( - "expected ERR_NEEDMOREPARAMS (461), got %v", + "expected ERR_NOTEXTTOSEND (412), got %v", msgs, ) } @@ -835,9 +835,9 @@ func TestMessageMissingTo(t *testing.T) { msgs, _ := tserver.pollMessages(token, lastID) - if !findNumeric(msgs, "461") { + if !findNumeric(msgs, "411") { t.Fatalf( - "expected ERR_NEEDMOREPARAMS (461), got %v", + "expected ERR_NORECIPIENT (411), got %v", msgs, ) } @@ -870,9 +870,9 @@ func TestNonMemberCannotSend(t *testing.T) { msgs, _ := tserver.pollMessages(aliceToken, lastID) - if !findNumeric(msgs, "442") { + if !findNumeric(msgs, "404") { t.Fatalf( - "expected ERR_NOTONCHANNEL (442), got %v", + "expected ERR_CANNOTSENDTOCHAN (404), got %v", msgs, ) } diff --git a/pkg/irc/commands.go b/pkg/irc/commands.go index 1446cff..fc2191b 100644 --- a/pkg/irc/commands.go +++ b/pkg/irc/commands.go @@ -2,6 +2,7 @@ package irc // IRC command names (RFC 1459 / RFC 2812). const ( + CmdAway = "AWAY" CmdJoin = "JOIN" CmdList = "LIST" CmdLusers = "LUSERS"