From 0d0d023a50b8196079849041ce031bde8a36c1ef Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 10 Mar 2026 10:12:42 -0700 Subject: [PATCH 1/2] feat: implement Tier 1 IRC numerics - AWAY command with RPL_AWAY (301), RPL_UNAWAY (305), RPL_NOWAWAY (306) - RPL_WHOISIDLE (317) with idle time and signon time in WHOIS - RPL_TOPICWHOTIME (333) with topic setter and timestamp - Schema migration 002 for away_message and topic metadata columns - Refactor deliverJoinNumerics into smaller helper functions --- internal/db/queries.go | 134 ++++++++++++ .../db/schema/002_away_and_topic_meta.sql | 6 + internal/handlers/api.go | 190 +++++++++++++++++- pkg/irc/commands.go | 1 + 4 files changed, 320 insertions(+), 11 deletions(-) create mode 100644 internal/db/schema/002_away_and_topic_meta.sql diff --git a/internal/db/queries.go b/internal/db/queries.go index 3a36a9b..c89803f 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -1110,6 +1110,140 @@ 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 +} + +// GetAwayByNick returns the away message for a nick. +// Returns an empty string if the user is not away. +func (database *Database) GetAwayByNick( + ctx context.Context, + nick string, +) (string, error) { + var msg string + + err := database.conn.QueryRowContext(ctx, + "SELECT away_message FROM sessions WHERE nick = ?", + nick, + ).Scan(&msg) + if err != nil { + return "", fmt.Errorf("get away by nick: %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/002_away_and_topic_meta.sql b/internal/db/schema/002_away_and_topic_meta.sql new file mode 100644 index 0000000..cf92eb3 --- /dev/null +++ b/internal/db/schema/002_away_and_topic_meta.sql @@ -0,0 +1,6 @@ +-- Add away message to sessions +ALTER TABLE sessions ADD COLUMN away_message TEXT NOT NULL DEFAULT ''; + +-- Add topic metadata to channels +ALTER TABLE channels ADD COLUMN topic_set_by TEXT NOT NULL DEFAULT ''; +ALTER TABLE channels ADD COLUMN topic_set_at DATETIME; diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 8dd41aa..8955c18 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -810,6 +810,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, @@ -1120,6 +1125,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) @@ -1230,14 +1248,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, ) @@ -1259,14 +1288,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, ) @@ -1289,8 +1343,6 @@ func (hdlr *Handlers) deliverJoinNumerics( ctx, clientID, irc.RplEndOfNames, nick, []string{channel}, "End of /NAMES list", ) - - hdlr.broker.Notify(sessionID) } func (hdlr *Handlers) handlePart( @@ -1574,8 +1626,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( @@ -1602,6 +1654,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, @@ -1991,6 +2062,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, @@ -2400,3 +2476,95 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc { }, http.StatusOK) } } + +// 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/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" -- 2.49.1 From 761bce32dee25c5cd7c43ccfc5534a164d3e1a63 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 10 Mar 2026 10:33:20 -0700 Subject: [PATCH 2/2] fix: remove dead code, fold migration 002 into 001, implement error numerics - Remove unused GetAwayByNick() from queries.go - Delete migration 002; fold away_message and topic_set_by/topic_set_at columns into migration 001 (pre-1.0 policy: single migration file) - PRIVMSG/NOTICE missing target now sends 411 ERR_NORECIPIENT - PRIVMSG/NOTICE missing body now sends 412 ERR_NOTEXTTOSEND - Non-member channel send now sends 404 ERR_CANNOTSENDTOCHAN - Auth failure now returns 451 ERR_NOTREGISTERED numeric in response - Update test assertions to match correct IRC numerics --- internal/db/queries.go | 19 ----------------- internal/db/schema/001_initial.sql | 3 +++ .../db/schema/002_away_and_topic_meta.sql | 6 ------ internal/handlers/api.go | 21 +++++++++---------- internal/handlers/api_test.go | 12 +++++------ 5 files changed, 19 insertions(+), 42 deletions(-) delete mode 100644 internal/db/schema/002_away_and_topic_meta.sql diff --git a/internal/db/queries.go b/internal/db/queries.go index c89803f..e97b1f0 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -1146,25 +1146,6 @@ func (database *Database) GetAway( return msg, nil } -// GetAwayByNick returns the away message for a nick. -// Returns an empty string if the user is not away. -func (database *Database) GetAwayByNick( - ctx context.Context, - nick string, -) (string, error) { - var msg string - - err := database.conn.QueryRowContext(ctx, - "SELECT away_message FROM sessions WHERE nick = ?", - nick, - ).Scan(&msg) - if err != nil { - return "", fmt.Errorf("get away by nick: %w", err) - } - - return msg, nil -} - // SetTopicMeta sets the topic along with who set it and // when. func (database *Database) SetTopicMeta( 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/db/schema/002_away_and_topic_meta.sql b/internal/db/schema/002_away_and_topic_meta.sql deleted file mode 100644 index cf92eb3..0000000 --- a/internal/db/schema/002_away_and_topic_meta.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Add away message to sessions -ALTER TABLE sessions ADD COLUMN away_message TEXT NOT NULL DEFAULT ''; - --- Add topic metadata to channels -ALTER TABLE channels ADD COLUMN topic_set_by TEXT NOT NULL DEFAULT ''; -ALTER TABLE channels ADD COLUMN topic_set_at DATETIME; diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 8955c18..6be9313 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 } @@ -925,8 +924,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, @@ -940,8 +939,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, @@ -1028,8 +1027,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 diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index d8eb7c8..57cad28 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -810,9 +810,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, ) } @@ -834,9 +834,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, ) } @@ -869,9 +869,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, ) } -- 2.49.1