Files
chat/internal/ircserver/commands.go
clawbot 92d5145ac6
Some checks failed
check / check (push) Failing after 46s
feat: add IRC wire protocol listener with shared service layer
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>
2026-03-25 18:01:36 -07:00

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