feat: implement IRC numerics batch 2 — connection registration, channel ops, user queries (#59)
All checks were successful
check / check (push) Successful in 5s

## Summary

Implements the remaining important/commonly-used IRC numeric reply codes, as requested in [issue #52](#52).

### Connection Registration (001-005)
- **002 RPL_YOURHOST** — "Your host is <server>, running version <ver>"
- **003 RPL_CREATED** — "This server was created <date>"
- **004 RPL_MYINFO** — "<server> <version> <usermodes> <chanmodes>"
- **005 RPL_ISUPPORT** — CHANTYPES=#, NICKLEN=32, CHANMODES, NETWORK=neoirc, CASEMAPPING=ascii

All sent automatically after RPL_WELCOME during session creation/login.

### Server Statistics (251-255)
- **251 RPL_LUSERCLIENT** — user count
- **252 RPL_LUSEROP** — operator count
- **254 RPL_LUSERCHANNELS** — channel count
- **255 RPL_LUSERME** — local client count

Sent during connection registration and available via LUSERS command.

### Channel Operations
- **MODE command** — query channel modes (324 RPL_CHANNELMODEIS + 329 RPL_CREATIONTIME) and user modes (221 RPL_UMODEIS)
- **NAMES command** — query channel member list (reuses 353/366)
- **LIST command** — list all channels with member counts (322 RPL_LIST + 323 end)

### User Queries
- **WHOIS command** — 311 RPL_WHOISUSER, 312 RPL_WHOISSERVER, 319 RPL_WHOISCHANNELS, 318 RPL_ENDOFWHOIS
- **WHO command** — 352 RPL_WHOREPLY, 315 RPL_ENDOFWHO

### Database Additions
- `GetChannelCount()` — total channel count for LUSERS
- `ListAllChannelsWithCounts()` — channels with member counts for LIST
- `GetChannelCreatedAt()` — channel creation time for RPL_CREATIONTIME
- `GetSessionCreatedAt()` — session creation time

### Other Changes
- Added `StartTime` to `Globals` struct for RPL_CREATED
- Updated README with comprehensive documentation of all new commands and numerics
- Updated roadmap to reflect implemented features

`docker build .` passes (lint, tests, build all green).

closes [#52](#52)

<!-- session: agent:sdlc-manager:subagent:1f3dcab8-ad6a-4c4c-af72-34a617640c9d -->

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: clawbot <clawbot@git.eeqj.de>
Reviewed-on: #59
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
Co-committed-by: clawbot <sneak+clawbot@sneak.cloud>
This commit was merged in pull request #59.
This commit is contained in:
2026-03-10 00:53:46 +01:00
committed by Jeffrey Paul
parent 47fb089969
commit 946f208ac2
9 changed files with 1128 additions and 320 deletions

View File

@@ -7,8 +7,10 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"strconv"
"time"
"git.eeqj.de/sneak/neoirc/internal/irc"
"github.com/google/uuid"
)
@@ -33,6 +35,7 @@ func generateToken() (string, error) {
type IRCMessage struct {
ID string `json:"id"`
Command string `json:"command"`
Code int `json:"code,omitempty"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
@@ -42,6 +45,15 @@ type IRCMessage struct {
DBID int64 `json:"-"`
}
// isNumericCode returns true if s is exactly a 3-digit
// IRC numeric reply code.
func isNumericCode(s string) bool {
return len(s) == 3 &&
s[0] >= '0' && s[0] <= '9' &&
s[1] >= '0' && s[1] <= '9' &&
s[2] >= '0' && s[2] <= '9'
}
// ChannelInfo is a lightweight channel representation.
type ChannelInfo struct {
ID int64 `json:"id"`
@@ -717,6 +729,15 @@ func scanMessages(
msg.DBID = qID
lastQID = qID
if isNumericCode(msg.Command) {
code, _ := strconv.Atoi(msg.Command)
msg.Code = code
if name := irc.Name(code); name != "" {
msg.Command = name
}
}
msgs = append(msgs, msg)
}
@@ -953,3 +974,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
}