check / check (push) Waiting to run
README.md rules that nicks are case-sensitive, but the IRC listener's relay and user MODE on both transports compared nicks ignoring case: an IRC listener user alice never saw a PRIVMSG, NOTICE, JOIN, PART, NICK or QUIT from Alice, and MODE Alice from alice was taken as her own nick. These checks now use ==. Tests cover alice receiving each of these from Alice, MODE Alice from alice getting 502 on both transports, and Alice registering while alice exists. README.md drops the text that described the old behaviour. Model: opus-5-5
382 lines
7.4 KiB
Go
382 lines
7.4 KiB
Go
package ircserver
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"sneak.berlin/go/neoirc/internal/db"
|
|
"sneak.berlin/go/neoirc/pkg/irc"
|
|
)
|
|
|
|
// relayMessages polls the client output queue and delivers
|
|
// IRC-formatted messages to the TCP connection. It runs
|
|
// in a goroutine for the lifetime of the connection.
|
|
func (c *Conn) relayMessages(ctx context.Context) {
|
|
// Use a ticker as a fallback; primary wakeup is via
|
|
// broker notification.
|
|
ticker := time.NewTicker(pollInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
// Drain any available messages.
|
|
delivered := c.drainQueue(ctx)
|
|
|
|
if delivered {
|
|
// Tight loop while there are messages.
|
|
continue
|
|
}
|
|
|
|
// Wait for notification or timeout.
|
|
waitCh := c.brk.Wait(c.sessionID)
|
|
|
|
select {
|
|
case <-waitCh:
|
|
// New message notification — loop back.
|
|
case <-ticker.C:
|
|
// Periodic check.
|
|
case <-ctx.Done():
|
|
c.brk.Remove(c.sessionID, waitCh)
|
|
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
const relayPollLimit = 100
|
|
|
|
// drainQueue polls the output queue and delivers all
|
|
// pending messages. Returns true if at least one message
|
|
// was delivered.
|
|
func (c *Conn) drainQueue(ctx context.Context) bool {
|
|
msgs, lastID, err := c.database.PollMessages(
|
|
ctx, c.clientID, c.lastQueueID, relayPollLimit,
|
|
)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
if len(msgs) == 0 {
|
|
return false
|
|
}
|
|
|
|
for i := range msgs {
|
|
c.deliverIRCMessage(ctx, &msgs[i])
|
|
}
|
|
|
|
if lastID > c.lastQueueID {
|
|
c.lastQueueID = lastID
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// deliverIRCMessage converts a db.IRCMessage to wire
|
|
// protocol and sends it.
|
|
//
|
|
//nolint:cyclop // dispatch table
|
|
func (c *Conn) deliverIRCMessage(
|
|
_ context.Context,
|
|
msg *db.IRCMessage,
|
|
) {
|
|
command := msg.Command
|
|
|
|
// Decode body as []string for the trailing text.
|
|
var bodyLines []string
|
|
|
|
if msg.Body != nil {
|
|
_ = json.Unmarshal(msg.Body, &bodyLines)
|
|
}
|
|
|
|
text := ""
|
|
if len(bodyLines) > 0 {
|
|
text = bodyLines[0]
|
|
}
|
|
|
|
// Route by command type.
|
|
switch {
|
|
case isNumeric(command):
|
|
c.deliverNumeric(msg, text)
|
|
case command == irc.CmdPrivmsg || command == irc.CmdNotice:
|
|
c.deliverTextMessage(msg, command, bodyLines)
|
|
case command == irc.CmdJoin:
|
|
c.deliverJoin(msg)
|
|
case command == irc.CmdPart:
|
|
c.deliverPart(msg, text)
|
|
case command == irc.CmdNick:
|
|
c.deliverNickChange(msg, text)
|
|
case command == irc.CmdQuit:
|
|
c.deliverQuitMsg(msg, text)
|
|
case command == irc.CmdTopic:
|
|
c.deliverTopicChange(msg, text)
|
|
case command == irc.CmdKick:
|
|
c.deliverKickMsg(msg, text)
|
|
case command == "INVITE":
|
|
c.deliverInviteMsg(msg, text)
|
|
case command == irc.CmdWallops:
|
|
c.deliverWallops(msg, text)
|
|
case command == irc.CmdMode:
|
|
c.deliverMode(msg, bodyLines)
|
|
case command == irc.CmdPing:
|
|
// Server-originated PING — reply with PONG.
|
|
c.sendFromServer("PING", c.serverSfx)
|
|
default:
|
|
// Unknown command — deliver as server notice.
|
|
if text != "" {
|
|
c.mu.Lock()
|
|
nick := c.nick
|
|
c.mu.Unlock()
|
|
|
|
c.sendFromServer("NOTICE", nick, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
// isNumeric returns true if the command is a 3-digit
|
|
// numeric code.
|
|
func isNumeric(cmd string) bool {
|
|
return len(cmd) == 3 &&
|
|
cmd[0] >= '0' && cmd[0] <= '9' &&
|
|
cmd[1] >= '0' && cmd[1] <= '9' &&
|
|
cmd[2] >= '0' && cmd[2] <= '9'
|
|
}
|
|
|
|
// deliverNumeric sends a numeric reply.
|
|
func (c *Conn) deliverNumeric(
|
|
msg *db.IRCMessage,
|
|
text string,
|
|
) {
|
|
from := msg.From
|
|
if from == "" {
|
|
from = c.serverSfx
|
|
}
|
|
|
|
var params []string
|
|
|
|
if msg.Params != nil {
|
|
_ = json.Unmarshal(msg.Params, ¶ms)
|
|
}
|
|
|
|
c.mu.Lock()
|
|
nick := c.nick
|
|
c.mu.Unlock()
|
|
|
|
allParams := make([]string, 0, 1+len(params)+1)
|
|
allParams = append(allParams, nick)
|
|
allParams = append(allParams, params...)
|
|
|
|
if text != "" {
|
|
allParams = append(allParams, text)
|
|
}
|
|
|
|
c.send(FormatMessage(from, msg.Command, allParams...))
|
|
}
|
|
|
|
// deliverTextMessage sends PRIVMSG or NOTICE, one line for
|
|
// each line of the body.
|
|
func (c *Conn) deliverTextMessage(
|
|
msg *db.IRCMessage,
|
|
command string,
|
|
bodyLines []string,
|
|
) {
|
|
from := msg.From
|
|
target := msg.To
|
|
|
|
c.mu.Lock()
|
|
nick := c.nick
|
|
c.mu.Unlock()
|
|
|
|
// Don't echo our own messages back.
|
|
if from == nick {
|
|
return
|
|
}
|
|
|
|
prefix := from
|
|
if !strings.Contains(prefix, "!") {
|
|
prefix = from + "!" + from + "@*"
|
|
}
|
|
|
|
for _, line := range bodyLines {
|
|
c.send(FormatMessage(prefix, command, target, line))
|
|
}
|
|
}
|
|
|
|
// deliverJoin sends a JOIN notification.
|
|
func (c *Conn) deliverJoin(msg *db.IRCMessage) {
|
|
c.mu.Lock()
|
|
nick := c.nick
|
|
c.mu.Unlock()
|
|
|
|
// Don't echo our own JOINs (we already sent them
|
|
// during joinChannel).
|
|
if msg.From == nick {
|
|
return
|
|
}
|
|
|
|
prefix := msg.From + "!" + msg.From + "@*"
|
|
channel := msg.To
|
|
|
|
c.send(FormatMessage(prefix, "JOIN", channel))
|
|
}
|
|
|
|
// deliverPart sends a PART notification.
|
|
func (c *Conn) deliverPart(msg *db.IRCMessage, text string) {
|
|
c.mu.Lock()
|
|
nick := c.nick
|
|
c.mu.Unlock()
|
|
|
|
if msg.From == nick {
|
|
return
|
|
}
|
|
|
|
prefix := msg.From + "!" + msg.From + "@*"
|
|
channel := msg.To
|
|
|
|
if text != "" {
|
|
c.send(FormatMessage(
|
|
prefix, "PART", channel, text,
|
|
))
|
|
} else {
|
|
c.send(FormatMessage(prefix, "PART", channel))
|
|
}
|
|
}
|
|
|
|
// deliverNickChange sends a NICK change notification.
|
|
func (c *Conn) deliverNickChange(
|
|
msg *db.IRCMessage,
|
|
newNick string,
|
|
) {
|
|
c.mu.Lock()
|
|
nick := c.nick
|
|
c.mu.Unlock()
|
|
|
|
if msg.From == nick {
|
|
return
|
|
}
|
|
|
|
prefix := msg.From + "!" + msg.From + "@*"
|
|
|
|
c.send(FormatMessage(prefix, "NICK", newNick))
|
|
}
|
|
|
|
// deliverQuitMsg sends a QUIT notification.
|
|
func (c *Conn) deliverQuitMsg(
|
|
msg *db.IRCMessage,
|
|
text string,
|
|
) {
|
|
c.mu.Lock()
|
|
nick := c.nick
|
|
c.mu.Unlock()
|
|
|
|
if msg.From == nick {
|
|
return
|
|
}
|
|
|
|
prefix := msg.From + "!" + msg.From + "@*"
|
|
|
|
if text != "" {
|
|
c.send(FormatMessage(
|
|
prefix, "QUIT", "Quit: "+text,
|
|
))
|
|
} else {
|
|
c.send(FormatMessage(prefix, "QUIT", "Quit"))
|
|
}
|
|
}
|
|
|
|
// deliverTopicChange sends a TOPIC change notification.
|
|
func (c *Conn) deliverTopicChange(
|
|
msg *db.IRCMessage,
|
|
text string,
|
|
) {
|
|
prefix := msg.From + "!" + msg.From + "@*"
|
|
channel := msg.To
|
|
|
|
c.send(FormatMessage(prefix, "TOPIC", channel, text))
|
|
}
|
|
|
|
// deliverKickMsg sends a KICK notification.
|
|
func (c *Conn) deliverKickMsg(
|
|
msg *db.IRCMessage,
|
|
text string,
|
|
) {
|
|
prefix := msg.From + "!" + msg.From + "@*"
|
|
channel := msg.To
|
|
|
|
var params []string
|
|
|
|
if msg.Params != nil {
|
|
_ = json.Unmarshal(msg.Params, ¶ms)
|
|
}
|
|
|
|
kickTarget := ""
|
|
if len(params) > 0 {
|
|
kickTarget = params[0]
|
|
}
|
|
|
|
if kickTarget != "" {
|
|
c.send(FormatMessage(
|
|
prefix, "KICK", channel, kickTarget, text,
|
|
))
|
|
} else {
|
|
c.send(FormatMessage(
|
|
prefix, "KICK", channel, "?", text,
|
|
))
|
|
}
|
|
}
|
|
|
|
// deliverInviteMsg sends an INVITE notification.
|
|
func (c *Conn) deliverInviteMsg(
|
|
_ *db.IRCMessage,
|
|
text string,
|
|
) {
|
|
c.mu.Lock()
|
|
nick := c.nick
|
|
c.mu.Unlock()
|
|
|
|
c.sendFromServer("NOTICE", nick, text)
|
|
}
|
|
|
|
// deliverWallops sends a WALLOPS notification.
|
|
func (c *Conn) deliverWallops(
|
|
msg *db.IRCMessage,
|
|
text string,
|
|
) {
|
|
prefix := msg.From + "!" + msg.From + "@*"
|
|
|
|
c.send(FormatMessage(
|
|
prefix, irc.CmdWallops, text,
|
|
))
|
|
}
|
|
|
|
// deliverMode sends a MODE change notification. The first
|
|
// body line is the change, possibly followed by its values
|
|
// separated by spaces ("+o bob"); any further lines are
|
|
// values ("+k", "secret"). Each goes out as its own
|
|
// parameter, as IRC sends them.
|
|
func (c *Conn) deliverMode(
|
|
msg *db.IRCMessage,
|
|
bodyLines []string,
|
|
) {
|
|
if len(bodyLines) == 0 || bodyLines[0] == "" {
|
|
return
|
|
}
|
|
|
|
prefix := msg.From + "!" + msg.From + "@*"
|
|
|
|
params := slices.Concat(
|
|
[]string{msg.To},
|
|
strings.Fields(bodyLines[0]),
|
|
bodyLines[1:],
|
|
)
|
|
|
|
c.send(FormatMessage(prefix, "MODE", params...))
|
|
}
|