Files
chat/internal/ircserver/commands.go
user 42157a7b23
All checks were successful
check / check (push) Successful in 58s
feat: add traditional IRC wire protocol listener on configurable port
Add a backward-compatible IRC protocol listener (RFC 1459/2812) that
allows standard IRC clients (irssi, weechat, hexchat, etc.) to connect
directly via TCP.

Key features:
- TCP listener on configurable port (IRC_LISTEN_ADDR env var, e.g. :6667)
- Full IRC wire protocol parsing and formatting
- Connection registration (NICK + USER + optional PASS)
- Channel operations: JOIN, PART, MODE, TOPIC, NAMES, LIST, KICK, INVITE
- Messaging: PRIVMSG, NOTICE (channel and direct)
- Info commands: WHO, WHOIS, LUSERS, MOTD, AWAY
- Operator support: OPER (with configured credentials)
- PING/PONG keepalive
- CAP negotiation (for modern client compatibility)
- Full bridge to HTTP/JSON API (shared DB, broker, sessions)
- Real-time message relay via broker notifications
- Comprehensive test suite (parser + integration tests)

The IRC listener is an optional component — disabled when IRC_LISTEN_ADDR
is empty (the default). The Broker is now an Fx-provided dependency shared
between HTTP handlers and the IRC server.

closes #89
2026-03-25 13:00:39 -07:00

1563 lines
27 KiB
Go

package ircserver
import (
"context"
"encoding/json"
"strconv"
"strings"
"time"
"git.eeqj.de/sneak/neoirc/pkg/irc"
)
// handleCAP silently acknowledges CAP negotiation.
func (c *Conn) handleCAP(msg *Message) {
if len(msg.Params) == 0 {
return
}
sub := strings.ToUpper(msg.Params[0])
if sub == "LS" {
c.send(FormatMessage(
c.serverSfx, "CAP", "*", "LS", "",
))
}
// CAP END and other subcommands are silently ignored.
}
// handlePing replies with a PONG.
func (c *Conn) handlePing(msg *Message) {
token := c.serverSfx
if len(msg.Params) > 0 {
token = msg.Params[0]
}
c.sendFromServer("PONG", c.serverSfx, token)
}
// handleNick changes the user's nickname.
func (c *Conn) handleNick(ctx context.Context, msg *Message) {
if len(msg.Params) < 1 {
c.sendNumeric(
irc.ErrNoNicknameGiven, "No nickname given",
)
return
}
newNick := msg.Params[0]
if len(newNick) > maxNickLen {
newNick = newNick[:maxNickLen]
}
oldMask := c.hostmask()
oldNick := c.nick
err := c.database.ChangeNick(
ctx, c.sessionID, newNick,
)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
c.sendNumeric(
irc.ErrNicknameInUse,
newNick, "Nickname is already in use",
)
return
}
c.sendNumeric(
irc.ErrErroneusNickname,
newNick, "Erroneous nickname",
)
return
}
c.mu.Lock()
c.nick = newNick
c.mu.Unlock()
// Echo NICK change to the client.
c.send(FormatMessage(oldMask, "NICK", newNick))
// Broadcast nick change to shared channels.
c.broadcastNickChange(ctx, oldNick, newNick)
}
// broadcastNickChange notifies channel peers of a nick
// change.
func (c *Conn) broadcastNickChange(
ctx context.Context,
oldNick, newNick string,
) {
channels, err := c.database.GetSessionChannels(
ctx, c.sessionID,
)
if err != nil {
return
}
body, _ := json.Marshal([]string{newNick}) //nolint:errchkjson
notified := make(map[int64]bool)
for _, ch := range channels {
chID, getErr := c.database.GetChannelByName(
ctx, ch.Name,
)
if getErr != nil {
continue
}
memberIDs, memErr := c.database.GetChannelMemberIDs(
ctx, chID,
)
if memErr != nil {
continue
}
for _, mid := range memberIDs {
if mid == c.sessionID || notified[mid] {
continue
}
notified[mid] = true
dbID, _, insErr := c.database.InsertMessage(
ctx, irc.CmdNick, oldNick, "",
nil, body, nil,
)
if insErr != nil {
continue
}
_ = c.database.EnqueueToSession(
ctx, mid, dbID,
)
c.brk.Notify(mid)
}
}
}
// handlePrivmsg handles PRIVMSG and NOTICE commands.
func (c *Conn) handlePrivmsg(
ctx context.Context,
msg *Message,
) {
if len(msg.Params) < 1 {
c.sendNumeric(
irc.ErrNoRecipient,
"No recipient given ("+msg.Command+")",
)
return
}
if len(msg.Params) < 2 { //nolint:mnd
c.sendNumeric(
irc.ErrNoTextToSend, "No text to send",
)
return
}
target := msg.Params[0]
text := msg.Params[1]
body, _ := json.Marshal([]string{text}) //nolint:errchkjson
if strings.HasPrefix(target, "#") {
c.handleChannelMsg(ctx, msg.Command, target, body)
} else {
c.handleDirectMsg(ctx, msg.Command, target, body)
}
}
// handleChannelMsg sends a message to a channel.
func (c *Conn) handleChannelMsg(
ctx context.Context,
command, channel string,
body json.RawMessage,
) {
chID, err := c.database.GetChannelByName(ctx, channel)
if err != nil {
c.sendNumeric(
irc.ErrNoSuchChannel,
channel, "No such channel",
)
return
}
isMember, _ := c.database.IsChannelMember(
ctx, chID, c.sessionID,
)
if !isMember {
c.sendNumeric(
irc.ErrCannotSendToChan,
channel, "Cannot send to channel",
)
return
}
// Check moderated mode.
moderated, _ := c.database.IsChannelModerated(ctx, chID)
if moderated {
isOp, _ := c.database.IsChannelOperator(
ctx, chID, c.sessionID,
)
isVoiced, _ := c.database.IsChannelVoiced(
ctx, chID, c.sessionID,
)
if !isOp && !isVoiced {
c.sendNumeric(
irc.ErrCannotSendToChan,
channel,
"Cannot send to channel (+m)",
)
return
}
}
memberIDs, _ := c.database.GetChannelMemberIDs(
ctx, chID,
)
// Fan out to all members except sender.
for _, mid := range memberIDs {
if mid == c.sessionID {
continue
}
dbID, _, insErr := c.database.InsertMessage(
ctx, command, c.nick, channel,
nil, body, nil,
)
if insErr != nil {
continue
}
_ = c.database.EnqueueToSession(ctx, mid, dbID)
c.brk.Notify(mid)
}
}
// handleDirectMsg sends a private message to a user.
func (c *Conn) handleDirectMsg(
ctx context.Context,
command, target string,
body json.RawMessage,
) {
targetID, err := c.database.GetSessionByNick(
ctx, target,
)
if err != nil {
c.sendNumeric(
irc.ErrNoSuchNick, target, "No such nick",
)
return
}
// Check AWAY status.
away, _ := c.database.GetAway(ctx, targetID)
if away != "" {
c.sendNumeric(
irc.RplAway, target, away,
)
}
dbID, _, insErr := c.database.InsertMessage(
ctx, command, c.nick, target,
nil, body, nil,
)
if insErr != nil {
return
}
_ = c.database.EnqueueToSession(ctx, targetID, dbID)
c.brk.Notify(targetID)
}
// handleJoin joins one or more channels.
func (c *Conn) handleJoin(ctx context.Context, msg *Message) {
if len(msg.Params) < 1 {
c.sendNumeric(
irc.ErrNeedMoreParams,
"JOIN", "Not enough parameters",
)
return
}
channels := strings.Split(msg.Params[0], ",")
for _, chanName := range channels {
chanName = strings.TrimSpace(chanName)
if !strings.HasPrefix(chanName, "#") {
chanName = "#" + chanName
}
c.joinChannel(ctx, chanName)
}
}
// joinChannel joins a single channel.
func (c *Conn) joinChannel(
ctx context.Context, channel string,
) {
chID, err := c.database.GetOrCreateChannel(ctx, channel)
if err != nil {
c.log.Error(
"get/create channel failed", "error", err,
)
return
}
// First joiner becomes operator.
memberCount, countErr := c.database.CountChannelMembers(
ctx, chID,
)
isCreator := countErr == nil && memberCount == 0
if isCreator {
err = c.database.JoinChannelAsOperator(
ctx, chID, c.sessionID,
)
} else {
err = c.database.JoinChannel(
ctx, chID, c.sessionID,
)
}
if err != nil {
return
}
// Fan out JOIN to all channel members.
memberIDs, _ := c.database.GetChannelMemberIDs(
ctx, chID,
)
joinBody, _ := json.Marshal([]string{channel}) //nolint:errchkjson
for _, mid := range memberIDs {
dbID, _, insErr := c.database.InsertMessage(
ctx, irc.CmdJoin, c.nick, channel,
nil, joinBody, nil,
)
if insErr != nil {
continue
}
_ = c.database.EnqueueToSession(ctx, mid, dbID)
c.brk.Notify(mid)
}
// Send JOIN echo to this client directly on wire.
c.send(FormatMessage(c.hostmask(), "JOIN", channel))
// Send topic.
c.deliverTopic(ctx, channel, chID)
// Send NAMES.
c.deliverNames(ctx, channel, chID)
}
// deliverTopic sends RPL_TOPIC or RPL_NOTOPIC.
func (c *Conn) deliverTopic(
ctx context.Context,
channel string,
chID int64,
) {
channels, err := c.database.ListChannels(
ctx, c.sessionID,
)
topic := ""
if err == nil {
for _, ch := range channels {
if ch.Name == channel {
topic = ch.Topic
break
}
}
}
if topic == "" {
c.sendNumeric(
irc.RplNoTopic, channel, "No topic is set",
)
return
}
c.sendNumeric(irc.RplTopic, channel, topic)
meta, tmErr := c.database.GetTopicMeta(ctx, chID)
if tmErr == nil && meta != nil {
c.sendNumeric(
irc.RplTopicWhoTime, channel,
meta.SetBy,
strconv.FormatInt(meta.SetAt.Unix(), 10),
)
}
}
// deliverNames sends RPL_NAMREPLY and RPL_ENDOFNAMES.
func (c *Conn) deliverNames(
ctx context.Context,
channel string,
chID int64,
) {
members, err := c.database.ChannelMembers(ctx, chID)
if err != nil {
c.sendNumeric(
irc.RplEndOfNames,
channel, "End of /NAMES list",
)
return
}
names := make([]string, 0, len(members))
for _, member := range members {
prefix := ""
if member.IsOperator {
prefix = "@"
} else if member.IsVoiced {
prefix = "+"
}
names = append(names, prefix+member.Nick)
}
nameStr := strings.Join(names, " ")
c.sendNumeric(
irc.RplNamReply, "=", channel, nameStr,
)
c.sendNumeric(
irc.RplEndOfNames,
channel, "End of /NAMES list",
)
}
// handlePart leaves one or more channels.
func (c *Conn) handlePart(
ctx context.Context,
msg *Message,
) {
if len(msg.Params) < 1 {
c.sendNumeric(
irc.ErrNeedMoreParams,
"PART", "Not enough parameters",
)
return
}
reason := ""
if len(msg.Params) > 1 {
reason = msg.Params[1]
}
channels := strings.Split(msg.Params[0], ",")
for _, ch := range channels {
ch = strings.TrimSpace(ch)
c.partChannel(ctx, ch, reason)
}
}
// partChannel leaves a single channel.
func (c *Conn) partChannel(
ctx context.Context,
channel, reason string,
) {
chID, err := c.database.GetChannelByName(ctx, channel)
if err != nil {
c.sendNumeric(
irc.ErrNoSuchChannel,
channel, "No such channel",
)
return
}
isMember, _ := c.database.IsChannelMember(
ctx, chID, c.sessionID,
)
if !isMember {
c.sendNumeric(
irc.ErrNotOnChannel,
channel, "You're not on that channel",
)
return
}
// Broadcast PART to channel members before leaving.
memberIDs, _ := c.database.GetChannelMemberIDs(
ctx, chID,
)
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
for _, mid := range memberIDs {
if mid == c.sessionID {
continue
}
dbID, _, insErr := c.database.InsertMessage(
ctx, irc.CmdPart, c.nick, channel,
nil, body, nil,
)
if insErr != nil {
continue
}
_ = c.database.EnqueueToSession(ctx, mid, dbID)
c.brk.Notify(mid)
}
// Echo PART to the client.
if reason != "" {
c.send(FormatMessage(
c.hostmask(), "PART", channel, reason,
))
} else {
c.send(FormatMessage(
c.hostmask(), "PART", channel,
))
}
c.database.PartChannel(ctx, chID, c.sessionID) //nolint:errcheck,gosec
c.database.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
}
// handleQuit handles the QUIT command.
func (c *Conn) handleQuit(msg *Message) {
reason := "Client quit"
if len(msg.Params) > 0 {
reason = msg.Params[0]
}
c.send("ERROR :Closing Link: " + c.hostname +
" (Quit: " + reason + ")")
c.closed = true
}
// handleTopic gets or sets a channel topic.
//
//nolint:funlen // coherent flow
func (c *Conn) handleTopic(
ctx context.Context,
msg *Message,
) {
if len(msg.Params) < 1 {
c.sendNumeric(
irc.ErrNeedMoreParams,
"TOPIC", "Not enough parameters",
)
return
}
channel := msg.Params[0]
chID, err := c.database.GetChannelByName(ctx, channel)
if err != nil {
c.sendNumeric(
irc.ErrNoSuchChannel,
channel, "No such channel",
)
return
}
// If no second param, query the topic.
if len(msg.Params) < 2 { //nolint:mnd
c.deliverTopic(ctx, channel, chID)
return
}
// Set topic — check permissions.
isMember, _ := c.database.IsChannelMember(
ctx, chID, c.sessionID,
)
if !isMember {
c.sendNumeric(
irc.ErrNotOnChannel,
channel, "You're not on that channel",
)
return
}
topicLocked, _ := c.database.IsChannelTopicLocked(
ctx, chID,
)
if topicLocked {
isOp, _ := c.database.IsChannelOperator(
ctx, chID, c.sessionID,
)
if !isOp {
c.sendNumeric(
irc.ErrChanOpPrivsNeeded,
channel,
"You're not channel operator",
)
return
}
}
newTopic := msg.Params[1]
err = c.database.SetTopic(ctx, channel, newTopic)
if err != nil {
return
}
_ = c.database.SetTopicMeta(
ctx, channel, newTopic, c.nick,
)
// Broadcast TOPIC to all members.
memberIDs, _ := c.database.GetChannelMemberIDs(
ctx, chID,
)
body, _ := json.Marshal([]string{newTopic}) //nolint:errchkjson
for _, mid := range memberIDs {
dbID, _, insErr := c.database.InsertMessage(
ctx, irc.CmdTopic, c.nick, channel,
nil, body, nil,
)
if insErr != nil {
continue
}
_ = c.database.EnqueueToSession(ctx, mid, dbID)
c.brk.Notify(mid)
}
// Echo to the setting client on wire.
c.send(FormatMessage(
c.hostmask(), "TOPIC", channel, newTopic,
))
}
// handleMode handles MODE queries and changes.
func (c *Conn) handleMode(
ctx context.Context,
msg *Message,
) {
if len(msg.Params) < 1 {
c.sendNumeric(
irc.ErrNeedMoreParams,
"MODE", "Not enough parameters",
)
return
}
target := msg.Params[0]
if strings.HasPrefix(target, "#") {
c.handleChannelMode(ctx, msg)
} else {
c.handleUserMode(ctx, msg)
}
}
// handleChannelMode handles MODE for channels.
func (c *Conn) handleChannelMode(
ctx context.Context,
msg *Message,
) {
channel := msg.Params[0]
chID, err := c.database.GetChannelByName(ctx, channel)
if err != nil {
c.sendNumeric(
irc.ErrNoSuchChannel,
channel, "No such channel",
)
return
}
// Query mode if no mode string given.
if len(msg.Params) < 2 { //nolint:mnd
modeStr := c.buildChannelModeString(ctx, chID)
c.sendNumeric(
irc.RplChannelModeIs, channel, modeStr,
)
created, _ := c.database.GetChannelCreatedAt(
ctx, chID,
)
if !created.IsZero() {
c.sendNumeric(
irc.RplCreationTime, channel,
strconv.FormatInt(created.Unix(), 10),
)
}
return
}
// Need ops to change modes.
isOp, _ := c.database.IsChannelOperator(
ctx, chID, c.sessionID,
)
if !isOp {
c.sendNumeric(
irc.ErrChanOpPrivsNeeded,
channel, "You're not channel operator",
)
return
}
modeStr := msg.Params[1]
modeArgs := msg.Params[2:]
c.applyChannelModes(ctx, channel, chID, modeStr, modeArgs)
}
// buildChannelModeString constructs the mode string for a
// channel.
func (c *Conn) buildChannelModeString(
ctx context.Context,
chID int64,
) string {
modes := "+"
moderated, _ := c.database.IsChannelModerated(ctx, chID)
if moderated {
modes += "m"
}
topicLocked, _ := c.database.IsChannelTopicLocked(
ctx, chID,
)
if topicLocked {
modes += "t"
}
if modes == "+" {
modes = "+"
}
return modes
}
// applyChannelModes applies mode changes.
//
//nolint:cyclop,funlen // mode parsing is inherently branchy
func (c *Conn) applyChannelModes(
ctx context.Context,
channel string,
chID int64,
modeStr string,
args []string,
) {
adding := true
argIdx := 0
applied := ""
appliedArgs := ""
for _, modeChar := range modeStr {
switch modeChar {
case '+':
adding = true
case '-':
adding = false
case 'm':
_ = c.database.SetChannelModerated(
ctx, chID, adding,
)
if adding {
applied += "+m"
} else {
applied += "-m"
}
case 't':
_ = c.database.SetChannelTopicLocked(
ctx, chID, adding,
)
if adding {
applied += "+t"
} else {
applied += "-t"
}
case 'o':
if argIdx >= len(args) {
break
}
targetNick := args[argIdx]
argIdx++
c.applyMemberMode(
ctx, chID, channel,
targetNick, 'o', adding,
)
if adding {
applied += "+o"
} else {
applied += "-o"
}
appliedArgs += " " + targetNick
case 'v':
if argIdx >= len(args) {
break
}
targetNick := args[argIdx]
argIdx++
c.applyMemberMode(
ctx, chID, channel,
targetNick, 'v', adding,
)
if adding {
applied += "+v"
} else {
applied += "-v"
}
appliedArgs += " " + targetNick
default:
c.sendNumeric(
irc.ErrUnknownMode,
string(modeChar),
"is unknown mode char to me",
)
}
}
if applied != "" {
modeReply := applied
if appliedArgs != "" {
modeReply += appliedArgs
}
c.send(FormatMessage(
c.hostmask(), "MODE", channel, modeReply,
))
}
}
// applyMemberMode applies +o/-o or +v/-v on a member.
func (c *Conn) applyMemberMode(
ctx context.Context,
chID int64,
channel, targetNick string,
mode rune,
adding bool,
) {
targetSessionID, err := c.database.GetSessionByNick(
ctx, targetNick,
)
if err != nil {
c.sendNumeric(
irc.ErrNoSuchNick,
targetNick, "No such nick/channel",
)
return
}
isMember, _ := c.database.IsChannelMember(
ctx, chID, targetSessionID,
)
if !isMember {
c.sendNumeric(
irc.ErrUserNotInChannel,
targetNick, channel,
"They aren't on that channel",
)
return
}
switch mode {
case 'o':
_ = c.database.SetChannelMemberOperator(
ctx, chID, targetSessionID, adding,
)
case 'v':
_ = c.database.SetChannelMemberVoiced(
ctx, chID, targetSessionID, adding,
)
}
}
// handleUserMode handles MODE for users.
func (c *Conn) handleUserMode(
_ context.Context,
msg *Message,
) {
target := msg.Params[0]
if !strings.EqualFold(target, c.nick) {
c.sendNumeric(
irc.ErrUsersDoNotMatch,
"Can't change mode for other users",
)
return
}
if len(msg.Params) < 2 { //nolint:mnd
c.sendNumeric(irc.RplUmodeIs, "+")
return
}
// We don't support user modes beyond the basics.
c.sendNumeric(irc.RplUmodeIs, "+")
}
// handleNames replies with channel member list.
func (c *Conn) handleNames(
ctx context.Context,
msg *Message,
) {
if len(msg.Params) < 1 {
c.sendNumeric(
irc.RplEndOfNames,
"*", "End of /NAMES list",
)
return
}
channel := msg.Params[0]
chID, err := c.database.GetChannelByName(ctx, channel)
if err != nil {
c.sendNumeric(
irc.RplEndOfNames,
channel, "End of /NAMES list",
)
return
}
c.deliverNames(ctx, channel, chID)
}
// handleList sends the channel list.
func (c *Conn) handleList(ctx context.Context) {
channels, err := c.database.ListAllChannelsWithCounts(
ctx,
)
if err != nil {
c.sendNumeric(
irc.RplListEnd, "End of /LIST",
)
return
}
c.sendNumeric(irc.RplListStart, "Channel", "Users Name")
for idx := range channels {
c.sendNumeric(
irc.RplList, channels[idx].Name,
strconv.FormatInt(
channels[idx].MemberCount, 10,
),
channels[idx].Topic,
)
}
c.sendNumeric(irc.RplListEnd, "End of /LIST")
}
// handleWhois replies with user info.
//
//nolint:funlen // WHOIS has many reply fields
func (c *Conn) handleWhois(
ctx context.Context,
msg *Message,
) {
if len(msg.Params) < 1 {
c.sendNumeric(
irc.ErrNoNicknameGiven, "No nickname given",
)
return
}
// The target nick may be the second param
// (WHOIS server nick).
target := msg.Params[0]
if len(msg.Params) > 1 {
target = msg.Params[1]
}
targetID, err := c.database.GetSessionByNick(
ctx, target,
)
if err != nil {
c.sendNumeric(
irc.ErrNoSuchNick, target, "No such nick",
)
c.sendNumeric(
irc.RplEndOfWhois,
target, "End of /WHOIS list",
)
return
}
// Get host info.
hostInfo, _ := c.database.GetSessionHostInfo(
ctx, targetID,
)
username := target
hostname := "*"
if hostInfo != nil {
username = hostInfo.Username
hostname = hostInfo.Hostname
}
c.sendNumeric(
irc.RplWhoisUser, target,
username, hostname, "*", target,
)
c.sendNumeric(
irc.RplWhoisServer, target,
c.serverSfx, "neoirc server",
)
// Check oper status.
isOper, _ := c.database.IsSessionOper(ctx, targetID)
if isOper {
c.sendNumeric(
irc.RplWhoisOperator,
target, "is an IRC operator",
)
}
// Get channels.
userChannels, _ := c.database.GetSessionChannels(
ctx, targetID,
)
if len(userChannels) > 0 {
var chanList []string
for _, userChan := range userChannels {
chID, getErr := c.database.GetChannelByName(
ctx, userChan.Name,
)
if getErr != nil {
chanList = append(
chanList, userChan.Name,
)
continue
}
isChOp, _ := c.database.IsChannelOperator(
ctx, chID, targetID,
)
isVoiced, _ := c.database.IsChannelVoiced(
ctx, chID, targetID,
)
prefix := ""
if isChOp {
prefix = "@"
} else if isVoiced {
prefix = "+"
}
chanList = append(
chanList, prefix+userChan.Name,
)
}
c.sendNumeric(
irc.RplWhoisChannels, target,
strings.Join(chanList, " "),
)
}
// Idle time.
lastSeen, _ := c.database.GetSessionLastSeen(
ctx, targetID,
)
created, _ := c.database.GetSessionCreatedAt(
ctx, targetID,
)
if !lastSeen.IsZero() {
idle := int64(time.Since(lastSeen).Seconds())
signonTS := int64(0)
if !created.IsZero() {
signonTS = created.Unix()
}
c.sendNumeric(
irc.RplWhoisIdle, target,
strconv.FormatInt(idle, 10),
strconv.FormatInt(signonTS, 10),
"seconds idle, signon time",
)
}
// Away.
away, _ := c.database.GetAway(ctx, targetID)
if away != "" {
c.sendNumeric(irc.RplAway, target, away)
}
c.sendNumeric(
irc.RplEndOfWhois,
target, "End of /WHOIS list",
)
}
// handleWho sends WHO replies for a channel.
func (c *Conn) handleWho(
ctx context.Context,
msg *Message,
) {
if len(msg.Params) < 1 {
c.sendNumeric(
irc.RplEndOfWho, "*", "End of /WHO list",
)
return
}
target := msg.Params[0]
if !strings.HasPrefix(target, "#") {
// WHO for a nick.
c.whoNick(ctx, target)
return
}
chID, err := c.database.GetChannelByName(ctx, target)
if err != nil {
c.sendNumeric(
irc.RplEndOfWho, target, "End of /WHO list",
)
return
}
members, err := c.database.ChannelMembers(ctx, chID)
if err != nil {
c.sendNumeric(
irc.RplEndOfWho, target, "End of /WHO list",
)
return
}
for _, member := range members {
flags := "H"
if member.IsOperator {
flags += "@"
} else if member.IsVoiced {
flags += "+"
}
c.sendNumeric(
irc.RplWhoReply,
target, member.Username, member.Hostname,
c.serverSfx, member.Nick, flags,
"0 "+member.Nick,
)
}
c.sendNumeric(
irc.RplEndOfWho, target, "End of /WHO list",
)
}
// whoNick sends WHO reply for a single nick.
func (c *Conn) whoNick(ctx context.Context, nick string) {
targetID, err := c.database.GetSessionByNick(ctx, nick)
if err != nil {
c.sendNumeric(
irc.RplEndOfWho, nick, "End of /WHO list",
)
return
}
hostInfo, _ := c.database.GetSessionHostInfo(
ctx, targetID,
)
username := nick
hostname := "*"
if hostInfo != nil {
username = hostInfo.Username
hostname = hostInfo.Hostname
}
c.sendNumeric(
irc.RplWhoReply,
"*", username, hostname,
c.serverSfx, nick, "H",
"0 "+nick,
)
c.sendNumeric(
irc.RplEndOfWho, nick, "End of /WHO list",
)
}
// handleLusers replies with server statistics.
func (c *Conn) handleLusers(ctx context.Context) {
c.deliverLusers(ctx)
}
// handleOper handles the OPER command.
func (c *Conn) handleOper(
ctx context.Context,
msg *Message,
) {
if len(msg.Params) < 2 { //nolint:mnd
c.sendNumeric(
irc.ErrNeedMoreParams,
"OPER", "Not enough parameters",
)
return
}
name := msg.Params[0]
password := msg.Params[1]
cfgName := c.cfg.OperName
cfgPassword := c.cfg.OperPassword
if cfgName == "" || cfgPassword == "" {
c.sendNumeric(
irc.ErrNoOperHost, "No O-lines for your host",
)
return
}
if name != cfgName || password != cfgPassword {
c.sendNumeric(
irc.ErrPasswdMismatch, "Password incorrect",
)
return
}
_ = c.database.SetSessionOper(ctx, c.sessionID, true)
c.sendNumeric(
irc.RplYoureOper,
"You are now an IRC operator",
)
}
// handleAway sets or clears the AWAY status.
func (c *Conn) handleAway(
ctx context.Context,
msg *Message,
) {
if len(msg.Params) < 1 || msg.Params[0] == "" {
_ = c.database.SetAway(ctx, c.sessionID, "")
c.sendNumeric(
irc.RplUnaway,
"You are no longer marked as being away",
)
return
}
_ = c.database.SetAway(ctx, c.sessionID, msg.Params[0])
c.sendNumeric(
irc.RplNowAway,
"You have been marked as being away",
)
}
// handleKick kicks a user from a channel.
//
//nolint:funlen // coherent flow
func (c *Conn) handleKick(
ctx context.Context,
msg *Message,
) {
if len(msg.Params) < 2 { //nolint:mnd
c.sendNumeric(
irc.ErrNeedMoreParams,
"KICK", "Not enough parameters",
)
return
}
channel := msg.Params[0]
targetNick := msg.Params[1]
reason := targetNick
if len(msg.Params) > 2 { //nolint:mnd
reason = msg.Params[2]
}
chID, err := c.database.GetChannelByName(ctx, channel)
if err != nil {
c.sendNumeric(
irc.ErrNoSuchChannel,
channel, "No such channel",
)
return
}
isOp, _ := c.database.IsChannelOperator(
ctx, chID, c.sessionID,
)
if !isOp {
c.sendNumeric(
irc.ErrChanOpPrivsNeeded,
channel, "You're not channel operator",
)
return
}
targetSessionID, err := c.database.GetSessionByNick(
ctx, targetNick,
)
if err != nil {
c.sendNumeric(
irc.ErrNoSuchNick,
targetNick, "No such nick/channel",
)
return
}
isMember, _ := c.database.IsChannelMember(
ctx, chID, targetSessionID,
)
if !isMember {
c.sendNumeric(
irc.ErrUserNotInChannel,
targetNick, channel,
"They aren't on that channel",
)
return
}
// Broadcast KICK to all channel members.
memberIDs, _ := c.database.GetChannelMemberIDs(
ctx, chID,
)
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
for _, mid := range memberIDs {
dbID, _, insErr := c.database.InsertMessage(
ctx, irc.CmdKick, c.nick, channel,
nil, body, nil,
)
if insErr != nil {
continue
}
_ = c.database.EnqueueToSession(ctx, mid, dbID)
c.brk.Notify(mid)
}
// Remove from channel.
c.database.PartChannel(ctx, chID, targetSessionID) //nolint:errcheck,gosec
c.database.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
// Echo KICK on wire.
c.send(FormatMessage(
c.hostmask(), "KICK", channel, targetNick, reason,
))
}
// handlePassPostReg handles PASS after registration (for
// setting a session password).
func (c *Conn) handlePassPostReg(
ctx context.Context,
msg *Message,
) {
if len(msg.Params) < 1 {
c.sendNumeric(
irc.ErrNeedMoreParams,
"PASS", "Not enough parameters",
)
return
}
password := msg.Params[0]
if len(password) < minPasswordLen {
c.sendFromServer("NOTICE", c.nick,
"Password must be at least 8 characters",
)
return
}
c.setPassword(ctx, password)
c.sendFromServer("NOTICE", c.nick,
"Password set. You can reconnect using "+
"PASS <password> with your nick.",
)
}
// handleInvite handles the INVITE command.
func (c *Conn) handleInvite(
ctx context.Context,
msg *Message,
) {
if len(msg.Params) < 2 { //nolint:mnd
c.sendNumeric(
irc.ErrNeedMoreParams,
"INVITE", "Not enough parameters",
)
return
}
targetNick := msg.Params[0]
channel := msg.Params[1]
chID, err := c.database.GetChannelByName(ctx, channel)
if err != nil {
c.sendNumeric(
irc.ErrNoSuchChannel,
channel, "No such channel",
)
return
}
isMember, _ := c.database.IsChannelMember(
ctx, chID, c.sessionID,
)
if !isMember {
c.sendNumeric(
irc.ErrNotOnChannel,
channel, "You're not on that channel",
)
return
}
targetID, err := c.database.GetSessionByNick(
ctx, targetNick,
)
if err != nil {
c.sendNumeric(
irc.ErrNoSuchNick,
targetNick, "No such nick",
)
return
}
c.sendNumeric(
irc.RplInviting, targetNick, channel,
)
// Send INVITE notice to target.
body, _ := json.Marshal( //nolint:errchkjson
[]string{"You have been invited to " + channel},
)
dbID, _, insErr := c.database.InsertMessage(
ctx, "INVITE", c.nick, targetNick,
nil, body, nil,
)
if insErr == nil {
_ = c.database.EnqueueToSession(
ctx, targetID, dbID,
)
c.brk.Notify(targetID)
}
}
// handleUserhost replies with USERHOST info.
func (c *Conn) handleUserhost(
ctx context.Context,
msg *Message,
) {
if len(msg.Params) < 1 {
return
}
replies := make([]string, 0, len(msg.Params))
for _, nick := range msg.Params {
sid, err := c.database.GetSessionByNick(ctx, nick)
if err != nil {
continue
}
hostInfo, _ := c.database.GetSessionHostInfo(
ctx, sid,
)
host := "*"
if hostInfo != nil {
host = hostInfo.Hostname
}
isOper, _ := c.database.IsSessionOper(ctx, sid)
operStar := ""
if isOper {
operStar = "*"
}
replies = append(
replies,
nick+operStar+"=+"+nick+"@"+host,
)
}
c.sendNumeric(
irc.RplUserHost,
strings.Join(replies, " "),
)
}