All checks were successful
check / check (push) Successful in 5s
## Summary Adds a backward-compatible IRC wire protocol listener (RFC 1459/2812) that allows standard IRC clients (irssi, weechat, hexchat, etc.) to connect directly via TCP. ## Changes ### New package: `internal/ircserver/` - **`parser.go`** — IRC wire protocol message parser and formatter - **`server.go`** — TCP listener with Fx lifecycle integration - **`conn.go`** — Per-connection handler with registration flow, PING/PONG, welcome burst - **`commands.go`** — All IRC command handlers (JOIN, PART, PRIVMSG, MODE, TOPIC, KICK, WHOIS, etc.) - **`relay.go`** — Message relay goroutine that delivers queued messages to IRC clients in wire format ### Modified files - **`internal/config/config.go`** — Added `IRC_LISTEN_ADDR` environment variable - **`internal/handlers/handlers.go`** — Broker is now injected via Fx (shared with IRC server) - **`cmd/neoircd/main.go`** — Registered `broker.New`, `ircserver.New` as Fx providers - **`pkg/irc/commands.go`** — Added `CmdUser` and `CmdInvite` constants - **`README.md`** — Added IRC Protocol Listener documentation section ### Tests - Parser unit tests (table-driven, round-trip verification) - Integration tests: registration, PING/PONG, JOIN, PART, PRIVMSG (channel + DM), NICK change, duplicate nick rejection, LIST, WHOIS, QUIT, TOPIC, MODE, WHO, LUSERS, MOTD, AWAY, PASS, CAP negotiation, unknown commands, pre-registration errors - Benchmarks for parser and formatter ## Key Design Decisions - **Optional**: Listener is only started when `IRC_LISTEN_ADDR` is set - **Shared infrastructure**: Same DB, broker, and session system as HTTP API - **Full bridge**: IRC ↔ HTTP messages are interoperable - **No schema changes**: Reuses existing tables - **Broker as Fx dependency**: Extracted from handlers to be shared ## Supported Commands Connection: NICK, USER, PASS, QUIT, PING/PONG, CAP Channels: JOIN, PART, MODE, TOPIC, NAMES, LIST, KICK, INVITE Messaging: PRIVMSG, NOTICE Info: WHO, WHOIS, LUSERS, MOTD, AWAY, USERHOST Operator: OPER closes #89 Co-authored-by: user <user@Mac.lan guest wan> Co-authored-by: Jeffrey Paul <sneak@noreply.example.org> Reviewed-on: sneak/chat#94 Co-authored-by: clawbot <sneak+clawbot@sneak.cloud> Co-committed-by: clawbot <sneak+clawbot@sneak.cloud>
124 lines
2.5 KiB
Go
124 lines
2.5 KiB
Go
// Package ircserver implements a traditional IRC wire protocol
|
|
// listener (RFC 1459/2812) that bridges to the neoirc HTTP/JSON
|
|
// server internals.
|
|
package ircserver
|
|
|
|
import "strings"
|
|
|
|
// Message represents a parsed IRC wire protocol message.
|
|
type Message struct {
|
|
// Prefix is the optional :prefix at the start (may be
|
|
// empty for client-to-server messages).
|
|
Prefix string
|
|
// Command is the IRC command (e.g., "PRIVMSG", "NICK").
|
|
Command string
|
|
// Params holds the positional parameters, including the
|
|
// trailing parameter (which was preceded by ':' on the
|
|
// wire).
|
|
Params []string
|
|
}
|
|
|
|
// ParseMessage parses a single IRC wire protocol line
|
|
// (without the trailing CR-LF) into a Message.
|
|
// Returns nil if the line is empty.
|
|
//
|
|
// IRC message format (RFC 1459 §2.3.1):
|
|
//
|
|
// [":" prefix SPACE] command { SPACE param } [SPACE ":" trailing]
|
|
func ParseMessage(line string) *Message {
|
|
if line == "" {
|
|
return nil
|
|
}
|
|
|
|
msg := &Message{} //nolint:exhaustruct // fields set below
|
|
|
|
// Extract prefix if present.
|
|
if line[0] == ':' {
|
|
idx := strings.IndexByte(line, ' ')
|
|
if idx < 0 {
|
|
// Only a prefix, no command — invalid.
|
|
return nil
|
|
}
|
|
|
|
msg.Prefix = line[1:idx]
|
|
line = line[idx+1:]
|
|
}
|
|
|
|
// Skip leading spaces.
|
|
line = strings.TrimLeft(line, " ")
|
|
if line == "" {
|
|
return nil
|
|
}
|
|
|
|
// Extract command.
|
|
idx := strings.IndexByte(line, ' ')
|
|
if idx < 0 {
|
|
msg.Command = strings.ToUpper(line)
|
|
|
|
return msg
|
|
}
|
|
|
|
msg.Command = strings.ToUpper(line[:idx])
|
|
line = line[idx+1:]
|
|
|
|
// Extract parameters.
|
|
for line != "" {
|
|
line = strings.TrimLeft(line, " ")
|
|
if line == "" {
|
|
break
|
|
}
|
|
|
|
// Trailing parameter (everything after ':').
|
|
if line[0] == ':' {
|
|
msg.Params = append(msg.Params, line[1:])
|
|
|
|
break
|
|
}
|
|
|
|
idx = strings.IndexByte(line, ' ')
|
|
if idx < 0 {
|
|
msg.Params = append(msg.Params, line)
|
|
|
|
break
|
|
}
|
|
|
|
msg.Params = append(msg.Params, line[:idx])
|
|
line = line[idx+1:]
|
|
}
|
|
|
|
return msg
|
|
}
|
|
|
|
// FormatMessage formats an IRC message into wire protocol
|
|
// format (without the trailing CR-LF).
|
|
func FormatMessage(
|
|
prefix, command string,
|
|
params ...string,
|
|
) string {
|
|
var buf strings.Builder
|
|
|
|
if prefix != "" {
|
|
buf.WriteByte(':')
|
|
buf.WriteString(prefix)
|
|
buf.WriteByte(' ')
|
|
}
|
|
|
|
buf.WriteString(command)
|
|
|
|
for i, param := range params {
|
|
buf.WriteByte(' ')
|
|
|
|
isLast := i == len(params)-1
|
|
needsColon := strings.Contains(param, " ") ||
|
|
param == "" || param[0] == ':'
|
|
|
|
if isLast && needsColon {
|
|
buf.WriteByte(':')
|
|
}
|
|
|
|
buf.WriteString(param)
|
|
}
|
|
|
|
return buf.String()
|
|
}
|