From 0d0d023a50b8196079849041ce031bde8a36c1ef Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 10 Mar 2026 10:12:42 -0700 Subject: [PATCH] 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"