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

Merged
sneak merged 4 commits from feature/irc-numerics-batch2 into main 2026-03-10 00:53:47 +01:00
7 changed files with 307 additions and 361 deletions
Showing only changes of commit 7bbd6de73a - Show all commits

View File

@@ -13,15 +13,14 @@ import (
"strconv"
"strings"
"time"
"git.eeqj.de/sneak/neoirc/internal/irc"
)
const (
httpTimeout = 30 * time.Second
pollExtraTime = 5
httpErrThreshold = 400
cmdJoin = "JOIN"
cmdPart = "PART"
)
var errHTTP = errors.New("HTTP error")
@@ -171,7 +170,7 @@ func (client *Client) PollMessages(
func (client *Client) JoinChannel(channel string) error {
return client.SendMessage(
&Message{ //nolint:exhaustruct // only command+to needed
Command: cmdJoin, To: channel,
Command: irc.CmdJoin, To: channel,
},
)
}
@@ -180,7 +179,7 @@ func (client *Client) JoinChannel(channel string) error {
func (client *Client) PartChannel(channel string) error {
return client.SendMessage(
&Message{ //nolint:exhaustruct // only command+to needed
Command: cmdPart, To: channel,
Command: irc.CmdPart, To: channel,
},
)
}

View File

@@ -9,6 +9,7 @@ import (
"time"
api "git.eeqj.de/sneak/neoirc/cmd/neoirc-cli/api"
"git.eeqj.de/sneak/neoirc/internal/irc"
)
const (
@@ -16,17 +17,6 @@ const (
pollTimeout = 15
pollRetry = 2 * time.Second
timeFormat = "15:04"
cmdJoin = "JOIN"
cmdMotd = "MOTD"
cmdNick = "NICK"
cmdNotice = "NOTICE"
cmdPart = "PART"
cmdPrivmsg = "PRIVMSG"
cmdQuit = "QUIT"
cmdTopic = "TOPIC"
cmdWho = "WHO"
cmdWhois = "WHOIS"
)
// App holds the application state.
@@ -97,7 +87,7 @@ func (a *App) handleInput(text string) {
}
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
Command: cmdPrivmsg,
Command: irc.CmdPrivmsg,
To: target,
Body: []string{text},
})
@@ -252,7 +242,7 @@ func (a *App) cmdNick(nick string) {
}
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
Command: cmdNick,
Command: irc.CmdNick,
Body: []string{nick},
})
if err != nil {
@@ -387,7 +377,7 @@ func (a *App) cmdMsg(args string) {
}
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
Command: cmdPrivmsg,
Command: irc.CmdPrivmsg,
To: target,
Body: []string{text},
})
@@ -445,7 +435,7 @@ func (a *App) cmdTopic(args string) {
if args == "" {
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
Command: cmdTopic,
Command: irc.CmdTopic,
To: target,
})
if err != nil {
@@ -458,7 +448,7 @@ func (a *App) cmdTopic(args string) {
}
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
Command: cmdTopic,
Command: irc.CmdTopic,
To: target,
Body: []string{args},
})
@@ -546,7 +536,7 @@ func (a *App) cmdMotd() {
}
err := a.client.SendMessage(
&api.Message{Command: cmdMotd}, //nolint:exhaustruct
&api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct
)
if err != nil {
a.ui.AddStatus(fmt.Sprintf(
@@ -583,7 +573,7 @@ func (a *App) cmdWho(args string) {
err := a.client.SendMessage(
&api.Message{ //nolint:exhaustruct
Command: cmdWho, To: channel,
Command: irc.CmdWho, To: channel,
},
)
if err != nil {
@@ -614,7 +604,7 @@ func (a *App) cmdWhois(args string) {
err := a.client.SendMessage(
&api.Message{ //nolint:exhaustruct
Command: cmdWhois, To: args,
Command: irc.CmdWhois, To: args,
},
)
if err != nil {
@@ -664,7 +654,7 @@ func (a *App) cmdQuit() {
if a.connected && a.client != nil {
_ = a.client.SendMessage(
&api.Message{Command: cmdQuit}, //nolint:exhaustruct
&api.Message{Command: irc.CmdQuit}, //nolint:exhaustruct
)
}
@@ -749,19 +739,19 @@ func (a *App) handleServerMessage(msg *api.Message) {
a.mu.Unlock()
switch msg.Command {
case cmdPrivmsg:
case irc.CmdPrivmsg:
a.handlePrivmsgEvent(msg, timestamp, myNick)
case cmdJoin:
case irc.CmdJoin:
a.handleJoinEvent(msg, timestamp)
case cmdPart:
case irc.CmdPart:
a.handlePartEvent(msg, timestamp)
case cmdQuit:
case irc.CmdQuit:
a.handleQuitEvent(msg, timestamp)
case cmdNick:
case irc.CmdNick:
a.handleNickEvent(msg, timestamp, myNick)
case cmdNotice:
case irc.CmdNotice:
a.handleNoticeEvent(msg, timestamp)
case cmdTopic:
case irc.CmdTopic:
a.handleTopicEvent(msg, timestamp)
default:
a.handleDefaultEvent(msg, timestamp)

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)
}

View File

@@ -10,6 +10,7 @@ import (
"strings"
"time"
"git.eeqj.de/sneak/neoirc/internal/irc"
"github.com/go-chi/chi"
)
@@ -27,22 +28,6 @@ const (
defaultMaxBodySize = 4096
defaultHistLimit = 50
maxHistLimit = 500
cmdJoin = "JOIN"
cmdList = "LIST"
cmdLusers = "LUSERS"
cmdMode = "MODE"
cmdMotd = "MOTD"
cmdNames = "NAMES"
cmdNick = "NICK"
cmdNotice = "NOTICE"
cmdPart = "PART"
cmdPing = "PING"
cmdPong = "PONG"
cmdPrivmsg = "PRIVMSG"
cmdQuit = "QUIT"
cmdTopic = "TOPIC"
cmdWho = "WHO"
cmdWhois = "WHOIS"
)
func (hdlr *Handlers) maxBodySize() int64 {
@@ -247,20 +232,20 @@ func (hdlr *Handlers) deliverWelcome(
// 001 RPL_WELCOME
hdlr.enqueueNumeric(
ctx, clientID, "001", nick, nil,
ctx, clientID, irc.RplWelcome, nick, nil,
"Welcome to the network, "+nick,
)
// 002 RPL_YOURHOST
hdlr.enqueueNumeric(
ctx, clientID, "002", nick, nil,
ctx, clientID, irc.RplYourHost, nick, nil,
"Your host is "+srvName+
", running version "+version,
)
// 003 RPL_CREATED
hdlr.enqueueNumeric(
ctx, clientID, "003", nick, nil,
ctx, clientID, irc.RplCreated, nick, nil,
"This server was created "+
hdlr.params.Globals.StartTime.
Format("2006-01-02"),
@@ -268,14 +253,14 @@ func (hdlr *Handlers) deliverWelcome(
// 004 RPL_MYINFO
hdlr.enqueueNumeric(
ctx, clientID, "004", nick,
ctx, clientID, irc.RplMyInfo, nick,
[]string{srvName, version, "", "imnst"},
"",
)
// 005 RPL_ISUPPORT
hdlr.enqueueNumeric(
ctx, clientID, "005", nick,
ctx, clientID, irc.RplIsupport, nick,
[]string{
"CHANTYPES=#",
"NICKLEN=32",
@@ -320,7 +305,7 @@ func (hdlr *Handlers) deliverLusers(
// 251 RPL_LUSERCLIENT
hdlr.enqueueNumeric(
ctx, clientID, "251", nick, nil,
ctx, clientID, irc.RplLuserClient, nick, nil,
fmt.Sprintf(
"There are %d users and 0 invisible on 1 servers",
userCount,
@@ -329,21 +314,21 @@ func (hdlr *Handlers) deliverLusers(
// 252 RPL_LUSEROP
hdlr.enqueueNumeric(
ctx, clientID, "252", nick,
ctx, clientID, irc.RplLuserOp, nick,
[]string{"0"},
"operator(s) online",
)
// 254 RPL_LUSERCHANNELS
hdlr.enqueueNumeric(
ctx, clientID, "254", nick,
ctx, clientID, irc.RplLuserChannels, nick,
[]string{strconv.FormatInt(chanCount, 10)},
"channels formed",
)
// 255 RPL_LUSERME
hdlr.enqueueNumeric(
ctx, clientID, "255", nick, nil,
ctx, clientID, irc.RplLuserMe, nick, nil,
fmt.Sprintf(
"I have %d clients and 1 servers",
userCount,
@@ -381,19 +366,19 @@ func (hdlr *Handlers) deliverMOTD(
}
hdlr.enqueueNumeric(
ctx, clientID, "375", nick, nil,
ctx, clientID, irc.RplMotdStart, nick, nil,
"- "+srvName+" Message of the Day -",
)
for line := range strings.SplitSeq(motd, "\n") {
hdlr.enqueueNumeric(
ctx, clientID, "372", nick, nil,
ctx, clientID, irc.RplMotd, nick, nil,
"- "+line,
)
}
hdlr.enqueueNumeric(
ctx, clientID, "376", nick, nil,
ctx, clientID, irc.RplEndOfMotd, nick, nil,
"End of /MOTD command.",
)
@@ -412,10 +397,13 @@ func (hdlr *Handlers) serverName() string {
func (hdlr *Handlers) enqueueNumeric(
ctx context.Context,
clientID int64,
command, nick string,
code int,
nick string,
params []string,
text string,
) {
command := fmt.Sprintf("%03d", code)
body, err := json.Marshal([]string{text})
if err != nil {
hdlr.log.Error(
@@ -765,38 +753,38 @@ func (hdlr *Handlers) dispatchCommand(
bodyLines func() []string,
) {
switch command {
case cmdPrivmsg, cmdNotice:
case irc.CmdPrivmsg, irc.CmdNotice:
hdlr.handlePrivmsg(
writer, request,
sessionID, clientID, nick,
command, target, body, bodyLines,
)
case cmdJoin:
case irc.CmdJoin:
hdlr.handleJoin(
writer, request,
sessionID, clientID, nick, target,
)
case cmdPart:
case irc.CmdPart:
hdlr.handlePart(
writer, request,
sessionID, clientID, nick, target, body,
)
case cmdNick:
case irc.CmdNick:
hdlr.handleNick(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case cmdTopic:
case irc.CmdTopic:
hdlr.handleTopic(
writer, request,
sessionID, clientID, nick,
target, body, bodyLines,
)
case cmdQuit:
case irc.CmdQuit:
hdlr.handleQuit(
writer, request, sessionID, nick, body,
)
case cmdMotd, cmdList, cmdWho, cmdWhois, cmdPing:
case irc.CmdMotd, irc.CmdPing:
hdlr.dispatchInfoCommand(
writer, request,
sessionID, clientID, nick,
@@ -819,34 +807,34 @@ func (hdlr *Handlers) dispatchQueryCommand(
bodyLines func() []string,
) {
switch command {
case cmdMode:
case irc.CmdMode:
hdlr.handleMode(
writer, request,
sessionID, clientID, nick,
target, bodyLines,
)
case cmdNames:
case irc.CmdNames:
hdlr.handleNames(
writer, request,
sessionID, clientID, nick, target,
)
case cmdList:
case irc.CmdList:
hdlr.handleList(
writer, request,
sessionID, clientID, nick,
)
case cmdWhois:
case irc.CmdWhois:
hdlr.handleWhois(
writer, request,
sessionID, clientID, nick,
target, bodyLines,
)
case cmdWho:
case irc.CmdWho:
hdlr.handleWho(
writer, request,
sessionID, clientID, nick, target,
)
case cmdLusers:
case irc.CmdLusers:
hdlr.handleLusers(
writer, request,
sessionID, clientID, nick,
@@ -854,7 +842,7 @@ func (hdlr *Handlers) dispatchQueryCommand(
default:
hdlr.enqueueNumeric(
request.Context(), clientID,
"421", nick, []string{command},
irc.ErrUnknownCommand, nick, []string{command},
"Unknown command",
)
hdlr.broker.Notify(sessionID)
@@ -875,7 +863,7 @@ func (hdlr *Handlers) handlePrivmsg(
if target == "" {
hdlr.enqueueNumeric(
request.Context(), clientID,
"461", nick, []string{command},
irc.ErrNeedMoreParams, nick, []string{command},
"Not enough parameters",
)
hdlr.broker.Notify(sessionID)
@@ -890,7 +878,7 @@ func (hdlr *Handlers) handlePrivmsg(
if len(lines) == 0 {
hdlr.enqueueNumeric(
request.Context(), clientID,
"461", nick, []string{command},
irc.ErrNeedMoreParams, nick, []string{command},
"Not enough parameters",
)
hdlr.broker.Notify(sessionID)
@@ -924,13 +912,14 @@ func (hdlr *Handlers) respondIRCError(
writer http.ResponseWriter,
request *http.Request,
clientID, sessionID int64,
numeric, nick string,
code int,
nick string,
params []string,
text string,
) {
hdlr.enqueueNumeric(
request.Context(), clientID,
numeric, nick, params, text,
code, nick, params, text,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
@@ -951,7 +940,7 @@ func (hdlr *Handlers) handleChannelMsg(
if err != nil {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"403", nick, []string{target},
irc.ErrNoSuchChannel, nick, []string{target},
"No such channel",
)
@@ -977,7 +966,7 @@ func (hdlr *Handlers) handleChannelMsg(
if !isMember {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"442", nick, []string{target},
irc.ErrNotOnChannel, nick, []string{target},
"You're not on that channel",
)
@@ -1044,7 +1033,7 @@ func (hdlr *Handlers) handleDirectMsg(
if err != nil {
hdlr.enqueueNumeric(
request.Context(), clientID,
"401", nick, []string{target},
irc.ErrNoSuchNick, nick, []string{target},
"No such nick/channel",
)
hdlr.broker.Notify(sessionID)
@@ -1088,7 +1077,7 @@ func (hdlr *Handlers) handleJoin(
if target == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"461", nick, []string{cmdJoin},
irc.ErrNeedMoreParams, nick, []string{irc.CmdJoin},
"Not enough parameters",
)
@@ -1103,7 +1092,7 @@ func (hdlr *Handlers) handleJoin(
if !validChannelRe.MatchString(channel) {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"403", nick, []string{channel},
irc.ErrNoSuchChannel, nick, []string{channel},
"No such channel",
)
@@ -1159,7 +1148,7 @@ func (hdlr *Handlers) executeJoin(
)
_ = hdlr.fanOutSilent(
request, cmdJoin, nick, channel, nil, memberIDs,
request, irc.CmdJoin, nick, channel, nil, memberIDs,
)
hdlr.deliverJoinNumerics(
@@ -1210,12 +1199,12 @@ func (hdlr *Handlers) deliverJoinNumerics(
if topic != "" {
hdlr.enqueueNumeric(
ctx, clientID, "332", nick,
ctx, clientID, irc.RplTopic, nick,
[]string{channel}, topic,
)
} else {
hdlr.enqueueNumeric(
ctx, clientID, "331", nick,
ctx, clientID, irc.RplNoTopic, nick,
[]string{channel}, "No topic is set",
)
}
@@ -1233,14 +1222,14 @@ func (hdlr *Handlers) deliverJoinNumerics(
}
hdlr.enqueueNumeric(
ctx, clientID, "353", nick,
ctx, clientID, irc.RplNamReply, nick,
[]string{"=", channel},
strings.Join(nicks, " "),
)
}
hdlr.enqueueNumeric(
ctx, clientID, "366", nick,
ctx, clientID, irc.RplEndOfNames, nick,
[]string{channel}, "End of /NAMES list",
)
@@ -1257,7 +1246,7 @@ func (hdlr *Handlers) handlePart(
if target == "" {
hdlr.enqueueNumeric(
request.Context(), clientID,
"461", nick, []string{cmdPart},
irc.ErrNeedMoreParams, nick, []string{irc.CmdPart},
"Not enough parameters",
)
hdlr.broker.Notify(sessionID)
@@ -1279,7 +1268,7 @@ func (hdlr *Handlers) handlePart(
if err != nil {
hdlr.enqueueNumeric(
request.Context(), clientID,
"403", nick, []string{channel},
irc.ErrNoSuchChannel, nick, []string{channel},
"No such channel",
)
hdlr.broker.Notify(sessionID)
@@ -1295,7 +1284,7 @@ func (hdlr *Handlers) handlePart(
)
_ = hdlr.fanOutSilent(
request, cmdPart, nick, channel, body, memberIDs,
request, irc.CmdPart, nick, channel, body, memberIDs,
)
err = hdlr.params.Database.PartChannel(
@@ -1337,7 +1326,7 @@ func (hdlr *Handlers) handleNick(
if len(lines) == 0 {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"461", nick, []string{cmdNick},
irc.ErrNeedMoreParams, nick, []string{irc.CmdNick},
"Not enough parameters",
)
@@ -1349,7 +1338,7 @@ func (hdlr *Handlers) handleNick(
if !validNickRe.MatchString(newNick) {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"432", nick, []string{newNick},
irc.ErrErroneusNickname, nick, []string{newNick},
"Erroneous nickname",
)
@@ -1385,7 +1374,7 @@ func (hdlr *Handlers) executeNickChange(
if strings.Contains(err.Error(), "UNIQUE") {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"433", nick, []string{newNick},
irc.ErrNicknameInUse, nick, []string{newNick},
"Nickname is already in use",
)
@@ -1435,7 +1424,7 @@ func (hdlr *Handlers) broadcastNick(
}
dbID, _, _ := hdlr.params.Database.InsertMessage(
request.Context(), cmdNick, oldNick, "",
request.Context(), irc.CmdNick, oldNick, "",
nil, json.RawMessage(nickBody), nil,
)
@@ -1476,7 +1465,7 @@ func (hdlr *Handlers) handleTopic(
if target == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"461", nick, []string{cmdTopic},
irc.ErrNeedMoreParams, nick, []string{irc.CmdTopic},
"Not enough parameters",
)
@@ -1487,7 +1476,7 @@ func (hdlr *Handlers) handleTopic(
if len(lines) == 0 {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"461", nick, []string{cmdTopic},
irc.ErrNeedMoreParams, nick, []string{irc.CmdTopic},
"Not enough parameters",
)
@@ -1505,7 +1494,7 @@ func (hdlr *Handlers) handleTopic(
if err != nil {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"403", nick, []string{channel},
irc.ErrNoSuchChannel, nick, []string{channel},
"No such channel",
)
@@ -1549,12 +1538,12 @@ func (hdlr *Handlers) executeTopic(
)
_ = hdlr.fanOutSilent(
request, cmdTopic, nick, channel, body, memberIDs,
request, irc.CmdTopic, nick, channel, body, memberIDs,
)
hdlr.enqueueNumeric(
request.Context(), clientID,
"332", nick, []string{channel}, topic,
irc.RplTopic, nick, []string{channel}, topic,
)
hdlr.broker.Notify(sessionID)
@@ -1566,8 +1555,7 @@ func (hdlr *Handlers) executeTopic(
}
// dispatchInfoCommand handles informational IRC commands
// that produce server-side numerics (MOTD, LIST, WHO,
// WHOIS, PING).
// that produce server-side numerics (MOTD, PING).
func (hdlr *Handlers) dispatchInfoCommand(
writer http.ResponseWriter,
request *http.Request,
@@ -1575,31 +1563,20 @@ func (hdlr *Handlers) dispatchInfoCommand(
nick, command, target string,
bodyLines func() []string,
) {
_ = target
_ = bodyLines
okResp := map[string]string{"status": "ok"}
switch command {
case cmdMotd:
case irc.CmdMotd:
hdlr.deliverMOTD(
request, clientID, sessionID, nick,
)
case cmdList:
hdlr.handleListCmd(
request, clientID, sessionID, nick,
)
case cmdWho:
hdlr.handleWhoCmd(
request, clientID, sessionID, nick,
target,
)
case cmdWhois:
hdlr.handleWhoisCmd(
request, clientID, sessionID, nick,
target, bodyLines,
)
case cmdPing:
case irc.CmdPing:
hdlr.respondJSON(writer, request,
map[string]string{
"command": cmdPong,
"command": irc.CmdPong,
"from": hdlr.serverName(),
},
http.StatusOK)
@@ -1612,222 +1589,6 @@ func (hdlr *Handlers) dispatchInfoCommand(
)
}
// handleListCmd sends RPL_LIST (322) for each channel,
// then sends 323 to signal the end of the list.
func (hdlr *Handlers) handleListCmd(
request *http.Request,
clientID, sessionID int64,
nick string,
) {
ctx := request.Context()
channels, err := hdlr.params.Database.ListAllChannels(
ctx,
)
if err != nil {
hdlr.enqueueNumeric(
ctx, clientID, "323", nick, nil,
"End of /LIST",
)
hdlr.broker.Notify(sessionID)
return
}
for _, channel := range channels {
memberIDs, _ :=
hdlr.params.Database.GetChannelMemberIDs(
ctx, channel.ID,
)
count := strconv.Itoa(len(memberIDs))
topic := channel.Topic
if topic == "" {
topic = " "
}
hdlr.enqueueNumeric(
ctx, clientID, "322", nick,
[]string{channel.Name, count}, topic,
)
}
hdlr.enqueueNumeric(
ctx, clientID, "323", nick, nil,
"End of /LIST",
)
hdlr.broker.Notify(sessionID)
}
// handleWhoCmd sends RPL_WHOREPLY (352) for each member
// of the target channel, followed by RPL_ENDOFWHO (315).
func (hdlr *Handlers) handleWhoCmd(
request *http.Request,
clientID, sessionID int64,
nick, target string,
) {
ctx := request.Context()
if target == "" {
hdlr.enqueueNumeric(
ctx, clientID, "461", nick,
[]string{cmdWho}, "Not enough parameters",
)
hdlr.broker.Notify(sessionID)
return
}
channel := target
if !strings.HasPrefix(channel, "#") {
channel = "#" + channel
}
chID, err := hdlr.params.Database.GetChannelByName(
ctx, channel,
)
if err != nil {
hdlr.enqueueNumeric(
ctx, clientID, "315", nick,
[]string{channel}, "End of /WHO list",
)
hdlr.broker.Notify(sessionID)
return
}
members, err := hdlr.params.Database.ChannelMembers(
ctx, chID,
)
if err == nil {
srvName := hdlr.serverName()
for _, mem := range members {
hdlr.enqueueNumeric(
ctx, clientID, "352", nick,
[]string{
channel, mem.Nick, "neoirc",
srvName, mem.Nick, "H",
},
"0 "+mem.Nick,
)
}
}
hdlr.enqueueNumeric(
ctx, clientID, "315", nick,
[]string{channel}, "End of /WHO list",
)
hdlr.broker.Notify(sessionID)
}
// handleWhoisCmd sends WHOIS reply numerics (311, 312,
// 319, 318) for the target nick.
func (hdlr *Handlers) handleWhoisCmd(
request *http.Request,
clientID, sessionID int64,
nick, target string,
bodyLines func() []string,
) {
ctx := request.Context()
whoisNick := target
if whoisNick == "" {
lines := bodyLines()
if len(lines) > 0 {
whoisNick = strings.TrimSpace(lines[0])
}
}
if whoisNick == "" {
hdlr.enqueueNumeric(
ctx, clientID, "461", nick,
[]string{cmdWhois}, "Not enough parameters",
)
hdlr.broker.Notify(sessionID)
return
}
targetSID, err :=
hdlr.params.Database.GetSessionByNick(
ctx, whoisNick,
)
if err != nil {
hdlr.enqueueNumeric(
ctx, clientID, "401", nick,
[]string{whoisNick},
"No such nick/channel",
)
hdlr.enqueueNumeric(
ctx, clientID, "318", nick,
[]string{whoisNick},
"End of /WHOIS list",
)
hdlr.broker.Notify(sessionID)
return
}
hdlr.sendWhoisNumerics(
ctx, clientID, sessionID, nick,
whoisNick, targetSID,
)
}
// sendWhoisNumerics emits 311/312/319/318 for a
// resolved WHOIS target.
func (hdlr *Handlers) sendWhoisNumerics(
ctx context.Context,
clientID, sessionID int64,
nick, whoisNick string,
targetSID int64,
) {
srvName := hdlr.serverName()
// 311 RPL_WHOISUSER
hdlr.enqueueNumeric(
ctx, clientID, "311", nick,
[]string{whoisNick, whoisNick, "neoirc", "*"},
whoisNick,
)
// 312 RPL_WHOISSERVER
hdlr.enqueueNumeric(
ctx, clientID, "312", nick,
[]string{whoisNick, srvName},
srvName,
)
// 319 RPL_WHOISCHANNELS
channels, _ := hdlr.params.Database.GetSessionChannels(
ctx, targetSID,
)
if len(channels) > 0 {
names := make([]string, 0, len(channels))
for _, chanInfo := range channels {
names = append(names, chanInfo.Name)
}
hdlr.enqueueNumeric(
ctx, clientID, "319", nick,
[]string{whoisNick},
strings.Join(names, " "),
)
}
// 318 RPL_ENDOFWHOIS
hdlr.enqueueNumeric(
ctx, clientID, "318", nick,
[]string{whoisNick},
"End of /WHOIS list",
)
hdlr.broker.Notify(sessionID)
}
func (hdlr *Handlers) handleQuit(
writer http.ResponseWriter,
request *http.Request,
@@ -1846,7 +1607,7 @@ func (hdlr *Handlers) handleQuit(
if len(channels) > 0 {
dbID, _, _ = hdlr.params.Database.InsertMessage(
request.Context(), cmdQuit, nick, "",
request.Context(), irc.CmdQuit, nick, "",
nil, body, nil,
)
}
@@ -1899,7 +1660,7 @@ func (hdlr *Handlers) handleMode(
if target == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"461", nick, []string{cmdMode},
irc.ErrNeedMoreParams, nick, []string{irc.CmdMode},
"Not enough parameters",
)
@@ -1911,7 +1672,7 @@ func (hdlr *Handlers) handleMode(
// User mode query — return empty modes.
hdlr.enqueueNumeric(
request.Context(), clientID,
"221", nick, nil, "+",
irc.RplUmodeIs, nick, nil, "+",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
@@ -1943,7 +1704,7 @@ func (hdlr *Handlers) handleChannelMode(
if err != nil {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"403", nick, []string{channel},
irc.ErrNoSuchChannel, nick, []string{channel},
"No such channel",
)
@@ -1952,7 +1713,7 @@ func (hdlr *Handlers) handleChannelMode(
// 324 RPL_CHANNELMODEIS
hdlr.enqueueNumeric(
ctx, clientID, "324", nick,
ctx, clientID, irc.RplChannelModeIs, nick,
[]string{channel, "+n"}, "",
)
@@ -1961,7 +1722,7 @@ func (hdlr *Handlers) handleChannelMode(
GetChannelCreatedAt(ctx, chID)
if timeErr == nil {
hdlr.enqueueNumeric(
ctx, clientID, "329", nick,
ctx, clientID, irc.RplCreationTime, nick,
[]string{
channel,
strconv.FormatInt(
@@ -1988,7 +1749,7 @@ func (hdlr *Handlers) handleNames(
if target == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"461", nick, []string{cmdNames},
irc.ErrNeedMoreParams, nick, []string{irc.CmdNames},
"Not enough parameters",
)
@@ -2008,7 +1769,7 @@ func (hdlr *Handlers) handleNames(
if err != nil {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"403", nick, []string{channel},
irc.ErrNoSuchChannel, nick, []string{channel},
"No such channel",
)
@@ -2026,14 +1787,14 @@ func (hdlr *Handlers) handleNames(
}
hdlr.enqueueNumeric(
ctx, clientID, "353", nick,
ctx, clientID, irc.RplNamReply, nick,
[]string{"=", channel},
strings.Join(nicks, " "),
)
}
hdlr.enqueueNumeric(
ctx, clientID, "366", nick,
ctx, clientID, irc.RplEndOfNames, nick,
[]string{channel}, "End of /NAMES list",
)
@@ -2071,7 +1832,7 @@ func (hdlr *Handlers) handleList(
for _, chanInfo := range channels {
// 322 RPL_LIST
hdlr.enqueueNumeric(
ctx, clientID, "322", nick,
ctx, clientID, irc.RplList, nick,
[]string{
chanInfo.Name,
strconv.FormatInt(
@@ -2084,7 +1845,7 @@ func (hdlr *Handlers) handleList(
// 323 — end of channel list.
hdlr.enqueueNumeric(
ctx, clientID, "323", nick, nil,
ctx, clientID, irc.RplListEnd, nick, nil,
"End of /LIST",
)
@@ -2115,7 +1876,7 @@ func (hdlr *Handlers) handleWhois(
if queryNick == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"461", nick, []string{cmdWhois},
irc.ErrNeedMoreParams, nick, []string{irc.CmdWhois},
"Not enough parameters",
)
@@ -2142,12 +1903,12 @@ func (hdlr *Handlers) executeWhois(
)
if err != nil {
hdlr.enqueueNumeric(
ctx, clientID, "401", nick,
ctx, clientID, irc.ErrNoSuchNick, nick,
[]string{queryNick},
"No such nick/channel",
)
hdlr.enqueueNumeric(
ctx, clientID, "318", nick,
ctx, clientID, irc.RplEndOfWhois, nick,
[]string{queryNick},
"End of /WHOIS list",
)
@@ -2161,14 +1922,14 @@ func (hdlr *Handlers) executeWhois(
// 311 RPL_WHOISUSER
hdlr.enqueueNumeric(
ctx, clientID, "311", nick,
ctx, clientID, irc.RplWhoisUser, nick,
[]string{queryNick, queryNick, srvName, "*"},
queryNick,
)
// 312 RPL_WHOISSERVER
hdlr.enqueueNumeric(
ctx, clientID, "312", nick,
ctx, clientID, irc.RplWhoisServer, nick,
[]string{queryNick, srvName},
"neoirc server",
)
@@ -2180,7 +1941,7 @@ func (hdlr *Handlers) executeWhois(
// 318 RPL_ENDOFWHOIS
hdlr.enqueueNumeric(
ctx, clientID, "318", nick,
ctx, clientID, irc.RplEndOfWhois, nick,
[]string{queryNick},
"End of /WHOIS list",
)
@@ -2210,7 +1971,7 @@ func (hdlr *Handlers) deliverWhoisChannels(
}
hdlr.enqueueNumeric(
ctx, clientID, "319", nick,
ctx, clientID, irc.RplWhoisChannels, nick,
[]string{queryNick},
strings.Join(chanNames, " "),
)
@@ -2226,7 +1987,7 @@ func (hdlr *Handlers) handleWho(
if target == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
"461", nick, []string{cmdWho},
irc.ErrNeedMoreParams, nick, []string{irc.CmdWho},
"Not enough parameters",
)
@@ -2247,7 +2008,7 @@ func (hdlr *Handlers) handleWho(
if err != nil {
// 315 RPL_ENDOFWHO (empty result)
hdlr.enqueueNumeric(
ctx, clientID, "315", nick,
ctx, clientID, irc.RplEndOfWho, nick,
[]string{target},
"End of /WHO list",
)
@@ -2266,7 +2027,7 @@ func (hdlr *Handlers) handleWho(
for _, mem := range members {
// 352 RPL_WHOREPLY
hdlr.enqueueNumeric(
ctx, clientID, "352", nick,
ctx, clientID, irc.RplWhoReply, nick,
[]string{
channel, mem.Nick, srvName,
srvName, mem.Nick, "H",
@@ -2278,7 +2039,7 @@ func (hdlr *Handlers) handleWho(
// 315 RPL_ENDOFWHO
hdlr.enqueueNumeric(
ctx, clientID, "315", nick,
ctx, clientID, irc.RplEndOfWho, nick,
[]string{channel},
"End of /WHO list",
)
@@ -2514,7 +2275,7 @@ func (hdlr *Handlers) cleanupUser(
if len(channels) > 0 {
quitDBID, _, _ = hdlr.params.Database.InsertMessage(
ctx, cmdQuit, nick, "",
ctx, irc.CmdQuit, nick, "",
nil, nil, nil,
)
}

View File

@@ -12,6 +12,7 @@ import (
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
@@ -467,8 +468,11 @@ func findNumeric(
msgs []map[string]any,
numeric string,
) bool {
want, _ := strconv.Atoi(numeric)
for _, msg := range msgs {
if msg[commandKey] == numeric {
code, ok := msg["code"].(float64)
if ok && int(code) == want {
return true
}
}

21
internal/irc/commands.go Normal file
View File

@@ -0,0 +1,21 @@
package irc
// IRC command names (RFC 1459 / RFC 2812).
const (
CmdJoin = "JOIN"
CmdList = "LIST"
CmdLusers = "LUSERS"
CmdMode = "MODE"
CmdMotd = "MOTD"
CmdNames = "NAMES"
CmdNick = "NICK"
CmdNotice = "NOTICE"
CmdPart = "PART"
CmdPing = "PING"
CmdPong = "PONG"
CmdPrivmsg = "PRIVMSG"
CmdQuit = "QUIT"
CmdTopic = "TOPIC"
CmdWho = "WHO"
CmdWhois = "WHOIS"
)

150
internal/irc/numerics.go Normal file
View File

@@ -0,0 +1,150 @@
// Package irc provides constants and utilities for the
// IRC protocol, including numeric reply codes from
// RFC 1459 and RFC 2812, and standard command names.
package irc
// Connection registration replies (001-005).
const (
RplWelcome = 1
RplYourHost = 2
RplCreated = 3
RplMyInfo = 4
RplIsupport = 5
)
// Command responses (200-399).
const (
RplUmodeIs = 221
RplLuserClient = 251
RplLuserOp = 252
RplLuserUnknown = 253
RplLuserChannels = 254
RplLuserMe = 255
RplAway = 301
RplUserHost = 302
RplIson = 303
RplUnaway = 305
RplNowAway = 306
RplWhoisUser = 311
RplWhoisServer = 312
RplWhoisOperator = 313
RplEndOfWho = 315
RplWhoisIdle = 317
RplEndOfWhois = 318
RplWhoisChannels = 319
RplList = 322
RplListEnd = 323
RplChannelModeIs = 324
RplCreationTime = 329
RplNoTopic = 331
RplTopic = 332
RplTopicWhoTime = 333
RplInviting = 341
RplWhoReply = 352
RplNamReply = 353
RplEndOfNames = 366
RplBanList = 367
RplEndOfBanList = 368
RplMotd = 372
RplMotdStart = 375
RplEndOfMotd = 376
)
// Error replies (400-599).
const (
ErrNoSuchNick = 401
ErrNoSuchServer = 402
ErrNoSuchChannel = 403
ErrCannotSendToChan = 404
ErrTooManyChannels = 405
ErrNoRecipient = 411
ErrNoTextToSend = 412
ErrUnknownCommand = 421
ErrNoNicknameGiven = 431
ErrErroneusNickname = 432
ErrNicknameInUse = 433
ErrUserNotInChannel = 441
ErrNotOnChannel = 442
ErrNotRegistered = 451
ErrNeedMoreParams = 461
ErrAlreadyRegistered = 462
ErrChannelIsFull = 471
ErrInviteOnlyChan = 473
ErrBannedFromChan = 474
ErrBadChannelKey = 475
ErrChanOpPrivsNeeded = 482
)
// names maps numeric codes to their standard IRC names.
//
//nolint:gochecknoglobals
var names = map[int]string{
RplWelcome: "RPL_WELCOME",
RplYourHost: "RPL_YOURHOST",
RplCreated: "RPL_CREATED",
RplMyInfo: "RPL_MYINFO",
RplIsupport: "RPL_ISUPPORT",
RplUmodeIs: "RPL_UMODEIS",
RplLuserClient: "RPL_LUSERCLIENT",
RplLuserOp: "RPL_LUSEROP",
RplLuserUnknown: "RPL_LUSERUNKNOWN",
RplLuserChannels: "RPL_LUSERCHANNELS",
RplLuserMe: "RPL_LUSERME",
RplAway: "RPL_AWAY",
RplUserHost: "RPL_USERHOST",
RplIson: "RPL_ISON",
RplUnaway: "RPL_UNAWAY",
RplNowAway: "RPL_NOWAWAY",
RplWhoisUser: "RPL_WHOISUSER",
RplWhoisServer: "RPL_WHOISSERVER",
RplWhoisOperator: "RPL_WHOISOPERATOR",
RplEndOfWho: "RPL_ENDOFWHO",
RplWhoisIdle: "RPL_WHOISIDLE",
RplEndOfWhois: "RPL_ENDOFWHOIS",
RplWhoisChannels: "RPL_WHOISCHANNELS",
RplList: "RPL_LIST",
RplListEnd: "RPL_LISTEND", //nolint:misspell
RplChannelModeIs: "RPL_CHANNELMODEIS",
RplCreationTime: "RPL_CREATIONTIME",
RplNoTopic: "RPL_NOTOPIC",
RplTopic: "RPL_TOPIC",
RplTopicWhoTime: "RPL_TOPICWHOTIME",
RplInviting: "RPL_INVITING",
RplWhoReply: "RPL_WHOREPLY",
RplNamReply: "RPL_NAMREPLY",
RplEndOfNames: "RPL_ENDOFNAMES",
RplBanList: "RPL_BANLIST",
RplEndOfBanList: "RPL_ENDOFBANLIST",
RplMotd: "RPL_MOTD",
RplMotdStart: "RPL_MOTDSTART",
RplEndOfMotd: "RPL_ENDOFMOTD",
ErrNoSuchNick: "ERR_NOSUCHNICK",
ErrNoSuchServer: "ERR_NOSUCHSERVER",
ErrNoSuchChannel: "ERR_NOSUCHCHANNEL",
ErrCannotSendToChan: "ERR_CANNOTSENDTOCHAN",
ErrTooManyChannels: "ERR_TOOMANYCHANNELS",
ErrNoRecipient: "ERR_NORECIPIENT",
ErrNoTextToSend: "ERR_NOTEXTTOSEND",
ErrUnknownCommand: "ERR_UNKNOWNCOMMAND",
ErrNoNicknameGiven: "ERR_NONICKNAMEGIVEN",
ErrErroneusNickname: "ERR_ERRONEUSNICKNAME",
ErrNicknameInUse: "ERR_NICKNAMEINUSE",
ErrUserNotInChannel: "ERR_USERNOTINCHANNEL",
ErrNotOnChannel: "ERR_NOTONCHANNEL",
ErrNotRegistered: "ERR_NOTREGISTERED",
ErrNeedMoreParams: "ERR_NEEDMOREPARAMS",
ErrAlreadyRegistered: "ERR_ALREADYREGISTERED",
ErrChannelIsFull: "ERR_CHANNELISFULL",
ErrInviteOnlyChan: "ERR_INVITEONLYCHAN",
ErrBannedFromChan: "ERR_BANNEDFROMCHAN",
ErrBadChannelKey: "ERR_BADCHANNELKEY",
ErrChanOpPrivsNeeded: "ERR_CHANOPRIVSNEEDED",
}
// Name returns the standard IRC name for a numeric code
// (e.g., Name(2) returns "RPL_YOURHOST"). Returns an
// empty string if the code is unknown.
func Name(code int) string {
return names[code]
}