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>
329 lines
6.3 KiB
Go
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,
|
|
)
|
|
}
|
|
}
|
|
}
|