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
This commit is contained in:
clawbot
2026-03-10 10:12:42 -07:00
committed by user
parent 27df999942
commit 0d0d023a50
4 changed files with 320 additions and 11 deletions

View File

@@ -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(

View File

@@ -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;

View File

@@ -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",
)
}

View File

@@ -2,6 +2,7 @@ package irc
// IRC command names (RFC 1459 / RFC 2812).
const (
CmdAway = "AWAY"
CmdJoin = "JOIN"
CmdList = "LIST"
CmdLusers = "LUSERS"