feat: implement Tier 1 IRC numerics
All checks were successful
check / check (push) Successful in 2m20s
All checks were successful
check / check (push) Successful in 2m20s
- 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:
@@ -1110,6 +1110,140 @@ func (database *Database) GetSessionCreatedAt(
|
|||||||
return createdAt, nil
|
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
|
// PruneOldQueueEntries deletes client output queue entries
|
||||||
// older than cutoff and returns the number of rows removed.
|
// older than cutoff and returns the number of rows removed.
|
||||||
func (database *Database) PruneOldQueueEntries(
|
func (database *Database) PruneOldQueueEntries(
|
||||||
|
|||||||
6
internal/db/schema/002_away_and_topic_meta.sql
Normal file
6
internal/db/schema/002_away_and_topic_meta.sql
Normal 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;
|
||||||
@@ -810,6 +810,11 @@ func (hdlr *Handlers) dispatchCommand(
|
|||||||
bodyLines func() []string,
|
bodyLines func() []string,
|
||||||
) {
|
) {
|
||||||
switch command {
|
switch command {
|
||||||
|
case irc.CmdAway:
|
||||||
|
hdlr.handleAway(
|
||||||
|
writer, request,
|
||||||
|
sessionID, clientID, nick, bodyLines,
|
||||||
|
)
|
||||||
case irc.CmdPrivmsg, irc.CmdNotice:
|
case irc.CmdPrivmsg, irc.CmdNotice:
|
||||||
hdlr.handlePrivmsg(
|
hdlr.handlePrivmsg(
|
||||||
writer, request,
|
writer, request,
|
||||||
@@ -1120,6 +1125,19 @@ func (hdlr *Handlers) handleDirectMsg(
|
|||||||
return
|
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,
|
hdlr.respondJSON(writer, request,
|
||||||
map[string]string{"id": msgUUID, "status": "sent"},
|
map[string]string{"id": msgUUID, "status": "sent"},
|
||||||
http.StatusOK)
|
http.StatusOK)
|
||||||
@@ -1230,14 +1248,25 @@ func (hdlr *Handlers) deliverJoinNumerics(
|
|||||||
) {
|
) {
|
||||||
ctx := request.Context()
|
ctx := request.Context()
|
||||||
|
|
||||||
chInfo, err := hdlr.params.Database.GetChannelByName(
|
hdlr.deliverTopicNumerics(
|
||||||
ctx, channel,
|
ctx, clientID, sessionID, nick, channel, chID,
|
||||||
)
|
)
|
||||||
if err == nil {
|
|
||||||
_ = chInfo // chInfo is the ID; topic comes from DB.
|
hdlr.deliverNamesNumerics(
|
||||||
|
ctx, clientID, nick, channel, chID,
|
||||||
|
)
|
||||||
|
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get topic from channel info.
|
// 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(
|
channels, listErr := hdlr.params.Database.ListChannels(
|
||||||
ctx, sessionID,
|
ctx, sessionID,
|
||||||
)
|
)
|
||||||
@@ -1259,14 +1288,39 @@ func (hdlr *Handlers) deliverJoinNumerics(
|
|||||||
ctx, clientID, irc.RplTopic, nick,
|
ctx, clientID, irc.RplTopic, nick,
|
||||||
[]string{channel}, topic,
|
[]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 {
|
} else {
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplNoTopic, nick,
|
ctx, clientID, irc.RplNoTopic, nick,
|
||||||
[]string{channel}, "No topic is set",
|
[]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(
|
members, memErr := hdlr.params.Database.ChannelMembers(
|
||||||
ctx, chID,
|
ctx, chID,
|
||||||
)
|
)
|
||||||
@@ -1289,8 +1343,6 @@ func (hdlr *Handlers) deliverJoinNumerics(
|
|||||||
ctx, clientID, irc.RplEndOfNames, nick,
|
ctx, clientID, irc.RplEndOfNames, nick,
|
||||||
[]string{channel}, "End of /NAMES list",
|
[]string{channel}, "End of /NAMES list",
|
||||||
)
|
)
|
||||||
|
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hdlr *Handlers) handlePart(
|
func (hdlr *Handlers) handlePart(
|
||||||
@@ -1574,8 +1626,8 @@ func (hdlr *Handlers) executeTopic(
|
|||||||
body json.RawMessage,
|
body json.RawMessage,
|
||||||
chID int64,
|
chID int64,
|
||||||
) {
|
) {
|
||||||
setErr := hdlr.params.Database.SetTopic(
|
setErr := hdlr.params.Database.SetTopicMeta(
|
||||||
request.Context(), channel, topic,
|
request.Context(), channel, topic, nick,
|
||||||
)
|
)
|
||||||
if setErr != nil {
|
if setErr != nil {
|
||||||
hdlr.log.Error(
|
hdlr.log.Error(
|
||||||
@@ -1602,6 +1654,25 @@ func (hdlr *Handlers) executeTopic(
|
|||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
irc.RplTopic, nick, []string{channel}, topic,
|
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.broker.Notify(sessionID)
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
@@ -1991,6 +2062,11 @@ func (hdlr *Handlers) executeWhois(
|
|||||||
"neoirc server",
|
"neoirc server",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 317 RPL_WHOISIDLE
|
||||||
|
hdlr.deliverWhoisIdle(
|
||||||
|
ctx, clientID, nick, queryNick, targetSID,
|
||||||
|
)
|
||||||
|
|
||||||
// 319 RPL_WHOISCHANNELS
|
// 319 RPL_WHOISCHANNELS
|
||||||
hdlr.deliverWhoisChannels(
|
hdlr.deliverWhoisChannels(
|
||||||
ctx, clientID, nick, queryNick, targetSID,
|
ctx, clientID, nick, queryNick, targetSID,
|
||||||
@@ -2400,3 +2476,95 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc {
|
|||||||
}, http.StatusOK)
|
}, 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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package irc
|
|||||||
|
|
||||||
// IRC command names (RFC 1459 / RFC 2812).
|
// IRC command names (RFC 1459 / RFC 2812).
|
||||||
const (
|
const (
|
||||||
|
CmdAway = "AWAY"
|
||||||
CmdJoin = "JOIN"
|
CmdJoin = "JOIN"
|
||||||
CmdList = "LIST"
|
CmdList = "LIST"
|
||||||
CmdLusers = "LUSERS"
|
CmdLusers = "LUSERS"
|
||||||
|
|||||||
Reference in New Issue
Block a user