package ircserver import ( "context" "encoding/json" "errors" "strconv" "strings" "time" "git.eeqj.de/sneak/neoirc/internal/service" "git.eeqj.de/sneak/neoirc/pkg/irc" ) // sendIRCError maps a service.IRCError to an IRC numeric // reply on the wire. func (c *Conn) sendIRCError(err error) { var ircErr *service.IRCError if errors.As(err, &ircErr) { args := make([]string, 0, len(ircErr.Params)+1) args = append(args, ircErr.Params...) args = append(args, ircErr.Message) c.sendNumeric(ircErr.Code, args...) } } // 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 via the shared // service layer. 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.svc.ChangeNick( ctx, c.sessionID, oldNick, newNick, ) if err != nil { c.sendIRCError(err) return } c.mu.Lock() c.nick = newNick c.mu.Unlock() // Echo NICK change to the client on wire. c.send(FormatMessage(oldMask, "NICK", newNick)) } // handlePrivmsg handles PRIVMSG and NOTICE commands via // the shared service layer. 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, "#") { _, _, err := c.svc.SendChannelMessage( ctx, c.sessionID, c.nick, msg.Command, target, body, nil, ) if err != nil { c.sendIRCError(err) } } else { result, err := c.svc.SendDirectMessage( ctx, c.sessionID, c.nick, msg.Command, target, body, nil, ) if err != nil { c.sendIRCError(err) return } if result.AwayMsg != "" { c.sendNumeric( irc.RplAway, target, result.AwayMsg, ) } } } // handleJoin joins one or more channels via the shared // service layer. 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 using the service // and delivers topic/names on the wire. func (c *Conn) joinChannel( ctx context.Context, channel string, ) { result, err := c.svc.JoinChannel( ctx, c.sessionID, c.nick, channel, ) if err != nil { c.sendIRCError(err) if !errors.As(err, new(*service.IRCError)) { c.log.Error( "join channel failed", "error", err, ) } return } // Send JOIN echo to this client directly on wire. c.send(FormatMessage(c.hostmask(), "JOIN", channel)) // Send topic. c.deliverTopic(ctx, channel, result.ChannelID) // Send NAMES. c.deliverNames(ctx, channel, result.ChannelID) } // 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 via the shared // service layer. 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 using the service. func (c *Conn) partChannel( ctx context.Context, channel, reason string, ) { err := c.svc.PartChannel( ctx, c.sessionID, c.nick, channel, reason, ) if err != nil { c.sendIRCError(err) return } // Echo PART to the client on wire. if reason != "" { c.send(FormatMessage( c.hostmask(), "PART", channel, reason, )) } else { c.send(FormatMessage( c.hostmask(), "PART", channel, )) } } // 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 via the shared // service layer. 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] // If no second param, query the topic. if len(msg.Params) < 2 { //nolint:mnd c.queryTopic(ctx, channel) return } // Set topic via service. newTopic := msg.Params[1] err := c.svc.SetTopic( ctx, c.sessionID, c.nick, channel, newTopic, ) if err != nil { c.sendIRCError(err) return } // Echo TOPIC to the setting client on wire. c.send(FormatMessage( c.hostmask(), "TOPIC", channel, newTopic, )) } // queryTopic sends the current topic for a channel. func (c *Conn) queryTopic( ctx context.Context, channel string, ) { chID, err := c.database.GetChannelByName(ctx, channel) if err != nil { c.sendNumeric( irc.ErrNoSuchChannel, channel, "No such channel", ) return } c.deliverTopic(ctx, channel, chID) } // 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(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.svc.QueryChannelMode(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 — validated by service. _, opErr := c.svc.ValidateChannelOp( ctx, c.sessionID, channel, ) if opErr != nil { c.sendIRCError(opErr) return } modeStr := msg.Params[1] modeArgs := msg.Params[2:] c.applyChannelModes( ctx, channel, chID, modeStr, modeArgs, ) } // applyChannelModes applies mode changes using the // service for individual mode operations. 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', 't': _ = c.svc.SetChannelFlag( ctx, chID, modeChar, adding, ) if adding { applied += "+" + string(modeChar) } else { applied += "-" + string(modeChar) } case 'o', 'v': if argIdx >= len(args) { break } targetNick := args[argIdx] argIdx++ err := c.svc.ApplyMemberMode( ctx, chID, channel, targetNick, modeChar, adding, ) if err != nil { c.sendIRCError(err) continue } if adding { applied += "+" + string(modeChar) } else { applied += "-" + string(modeChar) } 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, )) c.svc.BroadcastMode( ctx, c.nick, channel, chID, modeReply, ) } } // handleUserMode handles MODE for users. func (c *Conn) handleUserMode(msg *Message) { target := msg.Params[0] if !strings.EqualFold(target, c.nick) { c.sendNumeric( irc.ErrUsersDoNotMatch, "Can't change mode for other users", ) 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. Individual numeric // replies are split into focused helper methods. func (c *Conn) handleWhois( ctx context.Context, msg *Message, ) { if len(msg.Params) < 1 { c.sendNumeric( irc.ErrNoNicknameGiven, "No nickname given", ) return } 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 } c.whoisUser(ctx, target, targetID) c.whoisServer(target) c.whoisOper(ctx, target, targetID) c.whoisChannels(ctx, target, targetID) c.whoisIdle(ctx, target, targetID) c.whoisAway(ctx, target, targetID) c.sendNumeric( irc.RplEndOfWhois, target, "End of /WHOIS list", ) } // whoisUser sends 311 RPL_WHOISUSER. func (c *Conn) whoisUser( ctx context.Context, target string, targetID int64, ) { 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, ) } // whoisServer sends 312 RPL_WHOISSERVER. func (c *Conn) whoisServer(target string) { c.sendNumeric( irc.RplWhoisServer, target, c.serverSfx, "neoirc server", ) } // whoisOper sends 313 RPL_WHOISOPERATOR if applicable. func (c *Conn) whoisOper( ctx context.Context, target string, targetID int64, ) { isOper, _ := c.database.IsSessionOper(ctx, targetID) if isOper { c.sendNumeric( irc.RplWhoisOperator, target, "is an IRC operator", ) } } // whoisChannels sends 319 RPL_WHOISCHANNELS. func (c *Conn) whoisChannels( ctx context.Context, target string, targetID int64, ) { userChannels, _ := c.database.GetSessionChannels( ctx, targetID, ) if len(userChannels) == 0 { return } chanList := make([]string, 0, len(userChannels)) 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, " "), ) } // whoisIdle sends 317 RPL_WHOISIDLE. func (c *Conn) whoisIdle( ctx context.Context, target string, targetID int64, ) { lastSeen, _ := c.database.GetSessionLastSeen( ctx, targetID, ) created, _ := c.database.GetSessionCreatedAt( ctx, targetID, ) if lastSeen.IsZero() { return } 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", ) } // whoisAway sends 301 RPL_AWAY if the target is away. func (c *Conn) whoisAway( ctx context.Context, target string, targetID int64, ) { away, _ := c.database.GetAway(ctx, targetID) if away != "" { c.sendNumeric(irc.RplAway, target, away) } } // 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 via the shared // service layer. 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 } err := c.svc.Oper( ctx, c.sessionID, msg.Params[0], msg.Params[1], ) if err != nil { c.sendIRCError(err) return } c.sendNumeric( irc.RplYoureOper, "You are now an IRC operator", ) } // handleAway sets or clears the AWAY status via the // shared service layer. func (c *Conn) handleAway( ctx context.Context, msg *Message, ) { message := "" if len(msg.Params) > 0 { message = msg.Params[0] } cleared, err := c.svc.SetAway( ctx, c.sessionID, message, ) if err != nil { c.log.Error("set away failed", "error", err) return } if cleared { c.sendNumeric( irc.RplUnaway, "You are no longer marked as being away", ) } else { c.sendNumeric( irc.RplNowAway, "You have been marked as being away", ) } } // handleKick kicks a user from a channel via the shared // service layer. 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] } err := c.svc.KickUser( ctx, c.sessionID, c.nick, channel, targetNick, reason, ) if err != nil { c.sendIRCError(err) return } // 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 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 via service fan-out. body, _ := json.Marshal( //nolint:errchkjson []string{"You have been invited to " + channel}, ) _, _, _ = c.svc.FanOut( //nolint:dogsled // fire-and-forget broadcast ctx, "INVITE", c.nick, targetNick, nil, body, nil, []int64{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, " "), ) }