feat: implement IRC numerics batch 2 — connection registration, channel ops, user queries

Add comprehensive IRC numeric reply support:

Connection registration (001-005):
- 002 RPL_YOURHOST, 003 RPL_CREATED, 004 RPL_MYINFO, 005 RPL_ISUPPORT
- All sent automatically during session creation after RPL_WELCOME

Server statistics (251-255):
- RPL_LUSERCLIENT, RPL_LUSEROP, RPL_LUSERCHANNELS, RPL_LUSERME
- Sent during connection registration and via LUSERS command

Channel operations:
- MODE command: query channel modes (324 RPL_CHANNELMODEIS, 329 RPL_CREATIONTIME)
- MODE command: query user modes (221 RPL_UMODEIS)
- NAMES command: query channel member list (353/366)
- LIST command: list all channels (322 RPL_LIST, 323 end of list)

User queries:
- WHOIS command: 311/312/318/319 numerics
- WHO command: 352 RPL_WHOREPLY, 315 RPL_ENDOFWHO

Database additions:
- GetChannelCount, ListAllChannelsWithCounts
- GetChannelCreatedAt, GetSessionCreatedAt

Also adds StartTime to Globals for RPL_CREATED and updates README
with comprehensive documentation of all new commands and numerics.

closes #52
This commit is contained in:
clawbot
2026-03-09 14:53:49 -07:00
committed by user
parent 47fb089969
commit 8d26df60db
5 changed files with 857 additions and 24 deletions

View File

@@ -953,3 +953,125 @@ func (database *Database) GetSessionChannels(
return scanChannels(rows)
}
// GetChannelCount returns the total number of channels.
func (database *Database) GetChannelCount(
ctx context.Context,
) (int64, error) {
var count int64
err := database.conn.QueryRowContext(
ctx,
"SELECT COUNT(*) FROM channels",
).Scan(&count)
if err != nil {
return 0, fmt.Errorf(
"get channel count: %w", err,
)
}
return count, nil
}
// ChannelInfoFull contains extended channel information.
type ChannelInfoFull struct {
ID int64 `json:"id"`
Name string `json:"name"`
Topic string `json:"topic"`
MemberCount int64 `json:"memberCount"`
}
// ListAllChannelsWithCounts returns every channel
// with its member count.
func (database *Database) ListAllChannelsWithCounts(
ctx context.Context,
) ([]ChannelInfoFull, error) {
rows, err := database.conn.QueryContext(ctx,
`SELECT c.id, c.name, c.topic,
COUNT(cm.session_id) AS member_count
FROM channels c
LEFT JOIN channel_members cm
ON cm.channel_id = c.id
GROUP BY c.id
ORDER BY c.name`)
if err != nil {
return nil, fmt.Errorf(
"list channels with counts: %w", err,
)
}
defer func() { _ = rows.Close() }()
var out []ChannelInfoFull
for rows.Next() {
var chanInfo ChannelInfoFull
err = rows.Scan(
&chanInfo.ID, &chanInfo.Name,
&chanInfo.Topic, &chanInfo.MemberCount,
)
if err != nil {
return nil, fmt.Errorf(
"scan channel full: %w", err,
)
}
out = append(out, chanInfo)
}
err = rows.Err()
if err != nil {
return nil, fmt.Errorf("rows error: %w", err)
}
if out == nil {
out = []ChannelInfoFull{}
}
return out, nil
}
// GetChannelCreatedAt returns the creation time of a
// channel.
func (database *Database) GetChannelCreatedAt(
ctx context.Context,
channelID int64,
) (time.Time, error) {
var createdAt time.Time
err := database.conn.QueryRowContext(
ctx,
"SELECT created_at FROM channels WHERE id = ?",
channelID,
).Scan(&createdAt)
if err != nil {
return time.Time{}, fmt.Errorf(
"get channel created_at: %w", err,
)
}
return createdAt, nil
}
// GetSessionCreatedAt returns the creation time of a
// session.
func (database *Database) GetSessionCreatedAt(
ctx context.Context,
sessionID int64,
) (time.Time, error) {
var createdAt time.Time
err := database.conn.QueryRowContext(
ctx,
"SELECT created_at FROM sessions WHERE id = ?",
sessionID,
).Scan(&createdAt)
if err != nil {
return time.Time{}, fmt.Errorf(
"get session created_at: %w", err,
)
}
return createdAt, nil
}

View File

@@ -2,6 +2,8 @@
package globals
import (
"time"
"go.uber.org/fx"
)
@@ -15,16 +17,18 @@ var (
// Globals holds application-wide metadata.
type Globals struct {
Appname string
Version string
Appname string
Version string
StartTime time.Time
}
// New creates a new Globals instance from the global state.
func New(_ fx.Lifecycle) (*Globals, error) {
n := &Globals{
Appname: Appname,
Version: Version,
result := &Globals{
Appname: Appname,
Version: Version,
StartTime: time.Now(),
}
return n, nil
return result, nil
}

View File

@@ -219,19 +219,130 @@ func (hdlr *Handlers) handleCreateSessionError(
)
}
// deliverWelcome sends the RPL_WELCOME (001) numeric to a
// new client.
// deliverWelcome sends connection registration numerics
// (001-005) to a new client.
func (hdlr *Handlers) deliverWelcome(
request *http.Request,
clientID int64,
nick string,
) {
ctx := request.Context()
srvName := hdlr.serverName()
version := hdlr.serverVersion()
// 001 RPL_WELCOME
hdlr.enqueueNumeric(
ctx, clientID, "001", nick, nil,
"Welcome to the network, "+nick,
)
// 002 RPL_YOURHOST
hdlr.enqueueNumeric(
ctx, clientID, "002", nick, nil,
"Your host is "+srvName+
", running version "+version,
)
// 003 RPL_CREATED
hdlr.enqueueNumeric(
ctx, clientID, "003", nick, nil,
"This server was created "+
hdlr.params.Globals.StartTime.
Format("2006-01-02"),
)
// 004 RPL_MYINFO
hdlr.enqueueNumeric(
ctx, clientID, "004", nick,
[]string{srvName, version, "", "imnst"},
"",
)
// 005 RPL_ISUPPORT
hdlr.enqueueNumeric(
ctx, clientID, "005", nick,
[]string{
"CHANTYPES=#",
"NICKLEN=32",
"CHANMODES=,,," + "imnst",
"NETWORK=neoirc",
"CASEMAPPING=ascii",
},
"are supported by this server",
)
// LUSERS
hdlr.deliverLusers(ctx, clientID, nick)
}
// deliverLusers sends RPL_LUSERCLIENT (251),
// RPL_LUSEROP (252), RPL_LUSERCHANNELS (254), and
// RPL_LUSERME (255) to the client.
func (hdlr *Handlers) deliverLusers(
ctx context.Context,
clientID int64,
nick string,
) {
userCount, err := hdlr.params.Database.GetUserCount(ctx)
if err != nil {
hdlr.log.Error(
"lusers user count", "error", err,
)
userCount = 0
}
chanCount, err := hdlr.params.Database.GetChannelCount(
ctx,
)
if err != nil {
hdlr.log.Error(
"lusers channel count", "error", err,
)
chanCount = 0
}
// 251 RPL_LUSERCLIENT
hdlr.enqueueNumeric(
ctx, clientID, "251", nick, nil,
fmt.Sprintf(
"There are %d users and 0 invisible on 1 servers",
userCount,
),
)
// 252 RPL_LUSEROP
hdlr.enqueueNumeric(
ctx, clientID, "252", nick,
[]string{"0"},
"operator(s) online",
)
// 254 RPL_LUSERCHANNELS
hdlr.enqueueNumeric(
ctx, clientID, "254", nick,
[]string{strconv.FormatInt(chanCount, 10)},
"channels formed",
)
// 255 RPL_LUSERME
hdlr.enqueueNumeric(
ctx, clientID, "255", nick, nil,
fmt.Sprintf(
"I have %d clients and 1 servers",
userCount,
),
)
}
func (hdlr *Handlers) serverVersion() string {
ver := hdlr.params.Globals.Version
if ver == "" {
return "dev"
}
return ver
}
// deliverMOTD sends the MOTD as IRC numeric messages to a
@@ -676,6 +787,55 @@ func (hdlr *Handlers) dispatchCommand(
sessionID, clientID, nick,
command, target, bodyLines,
)
default:
hdlr.dispatchQueryCommand(
writer, request,
sessionID, clientID, nick,
command, target, bodyLines,
)
}
}
func (hdlr *Handlers) dispatchQueryCommand(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, command, target string,
bodyLines func() []string,
) {
switch command {
case "MODE":
hdlr.handleMode(
writer, request,
sessionID, clientID, nick,
target, bodyLines,
)
case "NAMES":
hdlr.handleNames(
writer, request,
sessionID, clientID, nick, target,
)
case "LIST":
hdlr.handleList(
writer, request,
sessionID, clientID, nick,
)
case "WHOIS":
hdlr.handleWhois(
writer, request,
sessionID, clientID, nick,
target, bodyLines,
)
case "WHO":
hdlr.handleWho(
writer, request,
sessionID, clientID, nick, target,
)
case "LUSERS":
hdlr.handleLusers(
writer, request,
sessionID, clientID, nick,
)
default:
hdlr.enqueueNumeric(
request.Context(), clientID,
@@ -1712,6 +1872,424 @@ func (hdlr *Handlers) handleQuit(
http.StatusOK)
}
// handleMode handles the MODE command for channels and
// users. Currently supports query-only (no mode changes).
func (hdlr *Handlers) handleMode(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, target string,
bodyLines func() []string,
) {
if target == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"461", nick, []string{"MODE"},
"Not enough parameters",
)
return
}
channel := target
if !strings.HasPrefix(channel, "#") {
// User mode query — return empty modes.
hdlr.enqueueNumeric(
request.Context(), clientID,
"221", nick, nil, "+",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
return
}
_ = bodyLines
hdlr.handleChannelMode(
writer, request,
sessionID, clientID, nick, channel,
)
}
func (hdlr *Handlers) handleChannelMode(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, channel string,
) {
ctx := request.Context()
chID, err := hdlr.params.Database.GetChannelByName(
ctx, channel,
)
if err != nil {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"403", nick, []string{channel},
"No such channel",
)
return
}
// 324 RPL_CHANNELMODEIS
hdlr.enqueueNumeric(
ctx, clientID, "324", nick,
[]string{channel, "+n"}, "",
)
// 329 RPL_CREATIONTIME
createdAt, timeErr := hdlr.params.Database.
GetChannelCreatedAt(ctx, chID)
if timeErr == nil {
hdlr.enqueueNumeric(
ctx, clientID, "329", nick,
[]string{
channel,
strconv.FormatInt(
createdAt.Unix(), 10,
),
},
"",
)
}
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleNames sends NAMES reply for a channel.
func (hdlr *Handlers) handleNames(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, target string,
) {
if target == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"461", nick, []string{"NAMES"},
"Not enough parameters",
)
return
}
channel := target
if !strings.HasPrefix(channel, "#") {
channel = "#" + channel
}
ctx := request.Context()
chID, err := hdlr.params.Database.GetChannelByName(
ctx, channel,
)
if err != nil {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"403", nick, []string{channel},
"No such channel",
)
return
}
members, memErr := hdlr.params.Database.ChannelMembers(
ctx, chID,
)
if memErr == nil && len(members) > 0 {
nicks := make([]string, 0, len(members))
for _, mem := range members {
nicks = append(nicks, mem.Nick)
}
hdlr.enqueueNumeric(
ctx, clientID, "353", nick,
[]string{"=", channel},
strings.Join(nicks, " "),
)
}
hdlr.enqueueNumeric(
ctx, clientID, "366", nick,
[]string{channel}, "End of /NAMES list",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleList sends the LIST response with 322/323
// numerics.
func (hdlr *Handlers) handleList(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
channels, err := hdlr.params.Database.
ListAllChannelsWithCounts(ctx)
if err != nil {
hdlr.log.Error(
"list channels failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
for _, chanInfo := range channels {
// 322 RPL_LIST
hdlr.enqueueNumeric(
ctx, clientID, "322", nick,
[]string{
chanInfo.Name,
strconv.FormatInt(
chanInfo.MemberCount, 10,
),
},
chanInfo.Topic,
)
}
// 323 — end of channel list.
hdlr.enqueueNumeric(
ctx, clientID, "323", nick, nil,
"End of /LIST",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleWhois handles the WHOIS command.
func (hdlr *Handlers) handleWhois(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, target string,
bodyLines func() []string,
) {
queryNick := target
// If target is empty, check body for the nick.
if queryNick == "" {
lines := bodyLines()
if len(lines) > 0 {
queryNick = strings.TrimSpace(lines[0])
}
}
if queryNick == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"461", nick, []string{"WHOIS"},
"Not enough parameters",
)
return
}
hdlr.executeWhois(
writer, request,
sessionID, clientID, nick, queryNick,
)
}
func (hdlr *Handlers) executeWhois(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, queryNick string,
) {
ctx := request.Context()
srvName := hdlr.serverName()
targetSID, err := hdlr.params.Database.GetSessionByNick(
ctx, queryNick,
)
if err != nil {
hdlr.enqueueNumeric(
ctx, clientID, "401", nick,
[]string{queryNick},
"No such nick/channel",
)
hdlr.enqueueNumeric(
ctx, clientID, "318", nick,
[]string{queryNick},
"End of /WHOIS list",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
return
}
// 311 RPL_WHOISUSER
hdlr.enqueueNumeric(
ctx, clientID, "311", nick,
[]string{queryNick, queryNick, srvName, "*"},
queryNick,
)
// 312 RPL_WHOISSERVER
hdlr.enqueueNumeric(
ctx, clientID, "312", nick,
[]string{queryNick, srvName},
"neoirc server",
)
// 319 RPL_WHOISCHANNELS
hdlr.deliverWhoisChannels(
ctx, clientID, nick, queryNick, targetSID,
)
// 318 RPL_ENDOFWHOIS
hdlr.enqueueNumeric(
ctx, clientID, "318", nick,
[]string{queryNick},
"End of /WHOIS list",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
func (hdlr *Handlers) deliverWhoisChannels(
ctx context.Context,
clientID int64,
nick, queryNick string,
targetSID int64,
) {
channels, chanErr := hdlr.params.Database.
GetSessionChannels(ctx, targetSID)
if chanErr != nil || len(channels) == 0 {
return
}
chanNames := make([]string, 0, len(channels))
for _, chanInfo := range channels {
chanNames = append(chanNames, chanInfo.Name)
}
hdlr.enqueueNumeric(
ctx, clientID, "319", nick,
[]string{queryNick},
strings.Join(chanNames, " "),
)
}
// handleWho handles the WHO command.
func (hdlr *Handlers) handleWho(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, target string,
) {
if target == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"461", nick, []string{"WHO"},
"Not enough parameters",
)
return
}
ctx := request.Context()
srvName := hdlr.serverName()
channel := target
if !strings.HasPrefix(channel, "#") {
channel = "#" + channel
}
chID, err := hdlr.params.Database.GetChannelByName(
ctx, channel,
)
if err != nil {
// 315 RPL_ENDOFWHO (empty result)
hdlr.enqueueNumeric(
ctx, clientID, "315", nick,
[]string{target},
"End of /WHO list",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
return
}
members, memErr := hdlr.params.Database.ChannelMembers(
ctx, chID,
)
if memErr == nil {
for _, mem := range members {
// 352 RPL_WHOREPLY
hdlr.enqueueNumeric(
ctx, clientID, "352", nick,
[]string{
channel, mem.Nick, srvName,
srvName, mem.Nick, "H",
},
"0 "+mem.Nick,
)
}
}
// 315 RPL_ENDOFWHO
hdlr.enqueueNumeric(
ctx, clientID, "315", nick,
[]string{channel},
"End of /WHO list",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleLusers handles the LUSERS command.
func (hdlr *Handlers) handleLusers(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
hdlr.deliverLusers(
request.Context(), clientID, nick,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// HandleGetHistory returns message history for a target.
func (hdlr *Handlers) HandleGetHistory() http.HandlerFunc {
return func(

View File

@@ -115,8 +115,9 @@ func newTestServer(
func newTestGlobals() *globals.Globals {
return &globals.Globals{
Appname: "neoirc-test",
Version: "test",
Appname: "neoirc-test",
Version: "test",
StartTime: time.Now(),
}
}