Some checks failed
check / check (push) Failing after 46s
Add a traditional IRC wire protocol listener (RFC 1459/2812) on configurable port (default :6667), sharing business logic with the HTTP API via a new service layer. - IRC listener: NICK, USER, PASS, JOIN, PART, PRIVMSG, NOTICE, TOPIC, MODE, KICK, QUIT, NAMES, LIST, WHOIS, WHO, AWAY, OPER, INVITE, LUSERS, MOTD, PING/PONG, CAP - Service layer: shared logic for both transports including channel join (with Tier 2 checks: ban/invite/key/limit), message send (with ban + moderation checks), nick change, topic, kick, mode, quit broadcast, away, oper, invite - BroadcastQuit uses FanOut pattern (one insert, N enqueues) - HTTP handlers delegate to service for all command logic - Tier 2 mode operations (+b/+i/+s/+k/+l) use service methods Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1179 lines
20 KiB
Go
1179 lines
20 KiB
Go
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 <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 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, " "),
|
|
)
|
|
}
|