All checks were successful
check / check (push) Successful in 58s
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
1563 lines
27 KiB
Go
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, " "),
|
|
)
|
|
}
|