Files
neoirc/internal/ircserver/parser_test.go
clawbot 0250f14fea
All checks were successful
check / check (push) Successful in 5s
feat: add traditional IRC wire protocol listener (closes #89) (#94)
## 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>
2026-04-01 05:00:04 +02:00

329 lines
6.3 KiB
Go

package ircserver_test
import (
"testing"
"git.eeqj.de/sneak/neoirc/internal/ircserver"
)
//nolint:funlen // table-driven test
func TestParseMessage(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want *ircserver.Message
wantNil bool
}{
{
name: "empty",
input: "",
want: nil,
wantNil: true,
},
{
name: "simple command",
input: "PING",
want: &ircserver.Message{
Prefix: "",
Command: "PING",
Params: nil,
},
wantNil: false,
},
{
name: "command with one param",
input: "NICK alice",
want: &ircserver.Message{
Prefix: "",
Command: "NICK",
Params: []string{"alice"},
},
wantNil: false,
},
{
name: "command case insensitive",
input: "nick Alice",
want: &ircserver.Message{
Prefix: "",
Command: "NICK",
Params: []string{"Alice"},
},
wantNil: false,
},
{
name: "privmsg with trailing",
input: "PRIVMSG #general :hello world",
want: &ircserver.Message{
Prefix: "",
Command: "PRIVMSG",
Params: []string{"#general", "hello world"},
},
wantNil: false,
},
{
name: "with prefix",
input: ":server.example.com 001 alice :Welcome to IRC",
want: &ircserver.Message{
Prefix: "server.example.com",
Command: "001",
Params: []string{"alice", "Welcome to IRC"},
},
wantNil: false,
},
{
name: "user command",
input: "USER alice 0 * :Alice Smith",
want: &ircserver.Message{
Prefix: "",
Command: "USER",
Params: []string{
"alice", "0", "*", "Alice Smith",
},
},
wantNil: false,
},
{
name: "join channel",
input: "JOIN #general",
want: &ircserver.Message{
Prefix: "",
Command: "JOIN",
Params: []string{"#general"},
},
wantNil: false,
},
{
name: "quit with trailing",
input: "QUIT :leaving now",
want: &ircserver.Message{
Prefix: "",
Command: "QUIT",
Params: []string{"leaving now"},
},
wantNil: false,
},
{
name: "quit without reason",
input: "QUIT",
want: &ircserver.Message{
Prefix: "",
Command: "QUIT",
Params: nil,
},
wantNil: false,
},
{
name: "mode query",
input: "MODE #general",
want: &ircserver.Message{
Prefix: "",
Command: "MODE",
Params: []string{"#general"},
},
wantNil: false,
},
{
name: "kick with reason",
input: "KICK #general bob :misbehaving",
want: &ircserver.Message{
Prefix: "",
Command: "KICK",
Params: []string{
"#general", "bob", "misbehaving",
},
},
wantNil: false,
},
{
name: "empty trailing",
input: "PRIVMSG #general :",
want: &ircserver.Message{
Prefix: "",
Command: "PRIVMSG",
Params: []string{"#general", ""},
},
wantNil: false,
},
{
name: "pass command",
input: "PASS mysecret",
want: &ircserver.Message{
Prefix: "",
Command: "PASS",
Params: []string{"mysecret"},
},
wantNil: false,
},
{
name: "ping with server",
input: "PING :irc.example.com",
want: &ircserver.Message{
Prefix: "",
Command: "PING",
Params: []string{"irc.example.com"},
},
wantNil: false,
},
{
name: "topic with trailing spaces",
input: "TOPIC #general :Welcome to the channel!",
want: &ircserver.Message{
Prefix: "",
Command: "TOPIC",
Params: []string{
"#general",
"Welcome to the channel!",
},
},
wantNil: false,
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
got := ircserver.ParseMessage(testCase.input)
if testCase.wantNil {
if got != nil {
t.Fatalf("expected nil, got %+v", got)
}
return
}
if got == nil {
t.Fatal("expected non-nil message")
}
if got.Prefix != testCase.want.Prefix {
t.Errorf(
"prefix: got %q, want %q",
got.Prefix, testCase.want.Prefix,
)
}
if got.Command != testCase.want.Command {
t.Errorf(
"command: got %q, want %q",
got.Command, testCase.want.Command,
)
}
if len(got.Params) != len(testCase.want.Params) {
t.Fatalf(
"params length: got %d, want %d (%v vs %v)",
len(got.Params),
len(testCase.want.Params),
got.Params,
testCase.want.Params,
)
}
for i, p := range got.Params {
if p != testCase.want.Params[i] {
t.Errorf(
"param[%d]: got %q, want %q",
i, p, testCase.want.Params[i],
)
}
}
})
}
}
func TestFormatMessage(t *testing.T) {
t.Parallel()
tests := []struct {
name string
prefix string
command string
params []string
want string
}{
{
name: "simple command",
prefix: "",
command: "PING",
params: nil,
want: "PING",
},
{
name: "with prefix",
prefix: "server",
command: "PONG",
params: []string{"server"},
want: ":server PONG server",
},
{
name: "privmsg with trailing",
prefix: "alice!alice@host",
command: "PRIVMSG",
params: []string{"#general", "hello world"},
want: ":alice!alice@host PRIVMSG #general :hello world",
},
{
name: "numeric reply",
prefix: "server",
command: "001",
params: []string{"alice", "Welcome to IRC"},
want: ":server 001 alice :Welcome to IRC",
},
{
name: "empty trailing",
prefix: "server",
command: "PRIVMSG",
params: []string{"#chan", ""},
want: ":server PRIVMSG #chan :",
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
got := ircserver.FormatMessage(
testCase.prefix, testCase.command, testCase.params...,
)
if got != testCase.want {
t.Errorf("got %q, want %q", got, testCase.want)
}
})
}
}
func TestParseFormatRoundTrip(t *testing.T) {
t.Parallel()
// Round-trip only works for lines where the last
// parameter either contains a space (gets ':' prefix
// on format) or is a non-trailing single token.
lines := []string{
"PING",
"NICK alice",
"PRIVMSG #general :hello world",
"JOIN #general",
"MODE #general",
}
for _, line := range lines {
msg := ircserver.ParseMessage(line)
if msg == nil {
t.Fatalf("failed to parse: %q", line)
}
formatted := ircserver.FormatMessage(
msg.Prefix, msg.Command, msg.Params...,
)
if formatted != line {
t.Errorf(
"round-trip failed: input %q, got %q",
line, formatted,
)
}
}
}