Author SHA1 Message Date
clawbot 6a200ea639 Read the IRC client's nick under its lock in the relay
check / check (push) Successful in 3m4s
After registration each IRC connection runs a second goroutine that sends
queued messages to the client, and it read c.nick without holding c.mu.
The goroutine that reads client commands changes c.nick under c.mu when
the client sends NICK, so the race detector failed
TestIntegrationTwoClients. Every read of c.nick in relay.go now takes
c.mu. No other field that goroutine reads changes after it starts.

Model: opus-5-5
2026-09-29 10:48:48 +00:00
clawbot 6a514b565c Build the HTTP router before the server starts (closes #104)
check / check (push) Successful in 2m22s
The fx OnStart hook started a goroutine that built the router and then
began serving, so OnStart returned before the router existed. Code that
served requests through the started Server, as the internal/handlers
tests do, raced with that goroutine or hit a nil router.

OnStart now configures the server, enables Sentry and builds the router
itself, and only then starts serving in the background. Sentry is set up
first because the routes depend on whether it is enabled. Run, whose
only caller was that hook, is folded into it.

Model: opus-5-5
2026-09-29 10:40:36 +00:00
clawbotandclawbot f829f9e3da Change module path to sneak.berlin/go/neoirc (#99)
check / check (push) Failing after 1m39s
Changes the Go module path from `git.eeqj.de/sneak/neoirc` to `sneak.berlin/go/neoirc`.

All occurrences updated:
- `go.mod` module directive
- All Go import paths across 35 `.go` files (107 import statements)
- All JSON schema `$id` URIs across 30 `.json` files in `schema/`

No functional changes — this is a pure rename of the module path.

`docker build .` passes clean (formatting, linting, all tests, binary build).

closes #98

Co-authored-by: clawbot <clawbot@users.noreply.git.eeqj.de>
Reviewed-on: #99
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-04-01 23:05:41 +02:00
clawbotanduser 02279b2654 test: add comprehensive IRC integration test with two clients (#100)
check / check (push) Successful in 6s
Adds `integration_test.go` with four test functions that exercise all major IRC features using real TCP connections.

## Tests

**TestIntegrationTwoClients** — sequential two-client test covering:
- NICK/USER registration (001-004 welcome burst)
- JOIN with cross-client visibility
- PRIVMSG channel (both directions)
- PRIVMSG DM (both directions)
- NOTICE channel and DM
- TOPIC set/get/lock/unlock
- MODE query, +m (moderated), +v (voice), -t/+t (topic lock)
- NAMES (with both nicks listed)
- LIST
- WHO
- WHOIS (with channels)
- LUSERS
- NICK change (with relay to other client)
- Duplicate NICK (ERR_NICKNAMEINUSE)
- KICK (with relay + reason)
- KICK non-op error (ERR_CHANOPRIVSNEEDED)
- PING/PONG
- Unknown command (ERR_UNKNOWNCOMMAND)
- MOTD
- AWAY set/clear/RPL_AWAY on DM
- PASS post-registration
- PART with reason + relay
- PART non-existent channel error
- User MODE query
- Multi-channel messaging
- QUIT with relay

**TestIntegrationModeSecret** — verifies +s mode set and query.

**TestIntegrationModeModerated** — verifies +m blocks non-voiced users and +v enables sending.

**TestIntegrationThirdClientObserver** — verifies three-client channel message fanout.

`docker build --no-cache .` passes clean (formatting, linting, all tests, build).

closes sneak/chat#97

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #100
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-04-01 12:47:49 +02:00
0250f14fea feat: add traditional IRC wire protocol listener (closes #89) (#94)
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>
2026-04-01 05:00:04 +02:00
clawbotanduser 24362966e0 feat: move schema_migrations into 000_bootstrap.sql (#95)
check / check (push) Successful in 5s
## Summary

- Moves schema_migrations table creation from inline Go code into internal/db/schema/000_bootstrap.sql
- Bootstrap SQL is executed directly before the migration loop (which starts from 001+)
- Go code does zero INSERTs for the bootstrap — 000_bootstrap.sql handles the INSERT OR IGNORE for version 0
- loadMigrations() skips 000.sql so it is not processed by the normal migration loop

Follows the sneak/pixa pattern.

closes #91

## Test plan

- [x] All existing tests pass (make test in Docker)
- [x] Linter passes (make lint)
- [x] Docker build succeeds (docker build --no-cache .)
- [x] Existing databases with schema_migrations table work (CREATE TABLE IF NOT EXISTS + INSERT OR IGNORE are idempotent)

Generated with Claude Code

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: sneak/chat#95
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
Co-committed-by: clawbot <sneak+clawbot@sneak.cloud>
2026-03-30 21:35:53 +02:00
67 changed files with 1123 additions and 161 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
// Package main is the entry point for the neoirc-cli client.
package main
import "git.eeqj.de/sneak/neoirc/internal/cli"
import "sneak.berlin/go/neoirc/internal/cli"
func main() {
cli.Run()
+12 -12
View File
@@ -2,19 +2,19 @@
package main
import (
"git.eeqj.de/sneak/neoirc/internal/broker"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/handlers"
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
"git.eeqj.de/sneak/neoirc/internal/ircserver"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/middleware"
"git.eeqj.de/sneak/neoirc/internal/server"
"git.eeqj.de/sneak/neoirc/internal/service"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/handlers"
"sneak.berlin/go/neoirc/internal/healthcheck"
"sneak.berlin/go/neoirc/internal/ircserver"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/middleware"
"sneak.berlin/go/neoirc/internal/server"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/internal/stats"
)
var (
+1 -1
View File
@@ -1,4 +1,4 @@
module git.eeqj.de/sneak/neoirc
module sneak.berlin/go/neoirc
go 1.24.0
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"testing"
"time"
"git.eeqj.de/sneak/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/broker"
)
func TestNewBroker(t *testing.T) {
+1 -1
View File
@@ -15,7 +15,7 @@ import (
"strings"
"time"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"sneak.berlin/go/neoirc/pkg/irc"
)
const (
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"math/big"
"time"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
"sneak.berlin/go/neoirc/internal/hashcash"
)
const (
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"sync"
"time"
api "git.eeqj.de/sneak/neoirc/internal/cli/api"
"git.eeqj.de/sneak/neoirc/pkg/irc"
api "sneak.berlin/go/neoirc/internal/cli/api"
"sneak.berlin/go/neoirc/pkg/irc"
)
const (
+2 -2
View File
@@ -5,10 +5,10 @@ import (
"errors"
"log/slog"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/logger"
"github.com/spf13/viper"
"go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/logger"
_ "github.com/joho/godotenv/autoload" // loads .env file
)
+20 -7
View File
@@ -12,9 +12,9 @@ import (
"strconv"
"strings"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/logger"
"go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/logger"
_ "github.com/joho/godotenv/autoload" // .env
_ "modernc.org/sqlite" // driver
@@ -135,13 +135,21 @@ type migration struct {
func (database *Database) runMigrations(
ctx context.Context,
) error {
_, err := database.conn.ExecContext(ctx,
`CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)`)
bootstrap, err := SchemaFiles.ReadFile(
"schema/000.sql",
)
if err != nil {
return fmt.Errorf(
"create schema_migrations: %w", err,
"read bootstrap migration: %w", err,
)
}
_, err = database.conn.ExecContext(
ctx, string(bootstrap),
)
if err != nil {
return fmt.Errorf(
"execute bootstrap migration: %w", err,
)
}
@@ -270,6 +278,11 @@ func (database *Database) loadMigrations() (
continue
}
// Skip bootstrap migration; it is executed separately.
if version == 0 {
continue
}
content, readErr := SchemaFiles.ReadFile(
"schema/" + entry.Name(),
)
+1 -1
View File
@@ -4,8 +4,8 @@ import (
"os"
"testing"
"git.eeqj.de/sneak/neoirc/internal/db"
"golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/db"
)
func TestMain(m *testing.M) {
+1 -1
View File
@@ -12,8 +12,8 @@ import (
"strings"
"time"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"github.com/google/uuid"
"sneak.berlin/go/neoirc/pkg/irc"
)
const (
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"encoding/json"
"testing"
"git.eeqj.de/sneak/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/db"
_ "modernc.org/sqlite"
)
+6
View File
@@ -0,0 +1,6 @@
-- Bootstrap: create the schema_migrations table itself.
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);
+4 -4
View File
@@ -12,11 +12,11 @@ import (
"strings"
"time"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
"git.eeqj.de/sneak/neoirc/internal/service"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"github.com/go-chi/chi/v5"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/hashcash"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/pkg/irc"
)
var (
+12 -12
View File
@@ -18,21 +18,21 @@ import (
"testing"
"time"
"git.eeqj.de/sneak/neoirc/internal/broker"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/handlers"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/middleware"
"git.eeqj.de/sneak/neoirc/internal/server"
"git.eeqj.de/sneak/neoirc/internal/service"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
"golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/handlers"
"sneak.berlin/go/neoirc/internal/hashcash"
"sneak.berlin/go/neoirc/internal/healthcheck"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/middleware"
"sneak.berlin/go/neoirc/internal/server"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/internal/stats"
)
func TestMain(m *testing.M) {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"net/http"
"strings"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"sneak.berlin/go/neoirc/pkg/irc"
)
const minPasswordLength = 8
+10 -10
View File
@@ -9,17 +9,17 @@ import (
"net/http"
"time"
"git.eeqj.de/sneak/neoirc/internal/broker"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
"git.eeqj.de/sneak/neoirc/internal/service"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/hashcash"
"sneak.berlin/go/neoirc/internal/healthcheck"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/ratelimit"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/internal/stats"
)
var errUnauthorized = errors.New("unauthorized")
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"encoding/hex"
"testing"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
"sneak.berlin/go/neoirc/internal/hashcash"
)
const (
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"testing"
"time"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
"sneak.berlin/go/neoirc/internal/hashcash"
)
const testBits = 2
+5 -5
View File
@@ -6,12 +6,12 @@ import (
"log/slog"
"time"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/stats"
"go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/stats"
)
// Params defines the dependencies for creating a Healthcheck.
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"strings"
"time"
"git.eeqj.de/sneak/neoirc/internal/service"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/pkg/irc"
)
// sendIRCError maps a service.IRCError to an IRC numeric
+5 -5
View File
@@ -11,11 +11,11 @@ import (
"sync"
"time"
"git.eeqj.de/sneak/neoirc/internal/broker"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/service"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/pkg/irc"
)
const (
+4 -4
View File
@@ -5,10 +5,10 @@ import (
"log/slog"
"net"
"git.eeqj.de/sneak/neoirc/internal/broker"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/service"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/service"
)
// NewTestServer creates a Server suitable for testing.
+913
View File
@@ -0,0 +1,913 @@
package ircserver_test
import (
"strings"
"testing"
"time"
)
// TestIntegrationTwoClients is a comprehensive integration
// test that spawns the IRC server programmatically, connects
// two real TCP clients, and verifies all major IRC features
// including cross-client message delivery.
//
// The test runs sequentially through IRC features because
// both clients share the same channel state. Each section
// builds on the previous one (e.g. alice and bob must be
// JOINed before PRIVMSG can be tested).
//
//nolint:cyclop,funlen,maintidx // integration test
func TestIntegrationTwoClients(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
bob := env.dial(t)
// ── Registration ──────────────────────────────────
aliceWelcome := alice.register("alice")
assertContains(
t, aliceWelcome, " 001 ", "RPL_WELCOME alice",
)
assertContains(
t, aliceWelcome, " 002 ", "RPL_YOURHOST alice",
)
assertContains(
t, aliceWelcome, " 003 ", "RPL_CREATED alice",
)
assertContains(
t, aliceWelcome, " 004 ", "RPL_MYINFO alice",
)
assertContains(
t, aliceWelcome, "alice",
"nick in welcome burst",
)
bobWelcome := bob.register("bob")
assertContains(
t, bobWelcome, " 001 ", "RPL_WELCOME bob",
)
assertContains(
t, bobWelcome, "bob",
"nick in welcome burst",
)
// ── JOIN and cross-client visibility ──────────────
alice.send("JOIN #integration")
aliceJoinLines := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 366 ")
})
assertContains(
t, aliceJoinLines, "JOIN",
"alice receives JOIN echo",
)
assertContains(
t, aliceJoinLines, " 366 ",
"RPL_ENDOFNAMES for alice",
)
bob.send("JOIN #integration")
bobJoinLines := bob.readUntil(func(l string) bool {
return strings.Contains(l, " 366 ")
})
assertContains(
t, bobJoinLines, "JOIN",
"bob receives JOIN echo",
)
// Alice should see bob's JOIN via relay.
aliceSeesBob := alice.readUntil(func(l string) bool {
return strings.Contains(l, "JOIN") &&
strings.Contains(l, "bob")
})
assertContains(
t, aliceSeesBob, "bob",
"alice sees bob's JOIN",
)
// ── PRIVMSG (channel) — alice to bob ──────────────
alice.send("PRIVMSG #integration :hello from alice")
bobGetsMsg := bob.readUntil(func(l string) bool {
return strings.Contains(l, "hello from alice")
})
assertContains(
t, bobGetsMsg, "hello from alice",
"bob receives alice's channel message",
)
// ── PRIVMSG (channel) — bob to alice ──────────────
bob.send("PRIVMSG #integration :hello from bob")
aliceGetsMsg := alice.readUntil(func(l string) bool {
return strings.Contains(l, "hello from bob")
})
assertContains(
t, aliceGetsMsg, "hello from bob",
"alice receives bob's channel message",
)
// ── PRIVMSG (DM) — alice to bob ──────────────────
alice.send("PRIVMSG bob :secret message")
bobDM := bob.readUntil(func(l string) bool {
return strings.Contains(l, "secret message")
})
assertContains(
t, bobDM, "secret message",
"bob receives alice's DM",
)
assertContains(
t, bobDM, "alice",
"DM from field is alice",
)
// ── PRIVMSG (DM) — bob to alice ──────────────────
bob.send("PRIVMSG alice :reply to you")
aliceDM := alice.readUntil(func(l string) bool {
return strings.Contains(l, "reply to you")
})
assertContains(
t, aliceDM, "reply to you",
"alice receives bob's DM",
)
// ── NOTICE (channel) ──────────────────────────────
alice.send("NOTICE #integration :notice msg")
bobNotice := bob.readUntil(func(l string) bool {
return strings.Contains(l, "notice msg")
})
assertContains(
t, bobNotice, "NOTICE",
"bob receives NOTICE command",
)
assertContains(
t, bobNotice, "notice msg",
"bob receives NOTICE text",
)
// ── NOTICE (DM) ──────────────────────────────────
bob.send("NOTICE alice :dm notice")
aliceNotice := alice.readUntil(func(l string) bool {
return strings.Contains(l, "dm notice")
})
assertContains(
t, aliceNotice, "dm notice",
"alice receives DM NOTICE",
)
// ── TOPIC ─────────────────────────────────────────
// alice is the channel creator so she is +o.
alice.send("TOPIC #integration :Integration Test Topic")
aliceTopic := alice.readUntil(func(l string) bool {
return strings.Contains(
l, "Integration Test Topic",
)
})
assertContains(
t, aliceTopic, "Integration Test Topic",
"alice sees TOPIC echo",
)
bobTopic := bob.readUntil(func(l string) bool {
return strings.Contains(
l, "Integration Test Topic",
)
})
assertContains(
t, bobTopic, "Integration Test Topic",
"bob receives TOPIC change",
)
// ── MODE (query) ──────────────────────────────────
alice.send("MODE #integration")
aliceMode := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 324 ")
})
assertContains(
t, aliceMode, " 324 ",
"RPL_CHANNELMODEIS",
)
// ── MODE (+m moderated, then -m) ──────────────────
alice.send("MODE #integration +m")
aliceModeM := alice.readUntil(func(l string) bool {
return strings.Contains(l, "MODE") &&
strings.Contains(l, "+m")
})
assertContains(
t, aliceModeM, "+m",
"alice sees MODE +m echo",
)
bobModeM := bob.readUntil(func(l string) bool {
return strings.Contains(l, "+m")
})
assertContains(
t, bobModeM, "+m",
"bob sees MODE +m relay",
)
// Revert moderated mode.
alice.send("MODE #integration -m")
alice.readUntil(func(l string) bool {
return strings.Contains(l, "-m")
})
bob.readUntil(func(l string) bool {
return strings.Contains(l, "-m")
})
// ── MODE (+v voice, then -v) ──────────────────────
alice.send("MODE #integration +v bob")
aliceVoice := alice.readUntil(func(l string) bool {
return strings.Contains(l, "+v")
})
assertContains(
t, aliceVoice, "+v",
"alice sees +v echo",
)
bobVoice := bob.readUntil(func(l string) bool {
return strings.Contains(l, "+v")
})
assertContains(
t, bobVoice, "+v",
"bob receives +v relay",
)
// Remove voice.
alice.send("MODE #integration -v bob")
alice.readUntil(func(l string) bool {
return strings.Contains(l, "-v")
})
bob.readUntil(func(l string) bool {
return strings.Contains(l, "-v")
})
// ── NAMES ─────────────────────────────────────────
alice.send("NAMES #integration")
aliceNames := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 366 ")
})
assertContains(
t, aliceNames, " 353 ",
"RPL_NAMREPLY",
)
assertContains(
t, aliceNames, " 366 ",
"RPL_ENDOFNAMES",
)
// Both nicks should appear in the name list.
foundBothNames := false
for _, line := range aliceNames {
if strings.Contains(line, " 353 ") &&
strings.Contains(line, "alice") &&
strings.Contains(line, "bob") {
foundBothNames = true
break
}
}
if !foundBothNames {
t.Error("NAMES reply should list both alice and bob")
}
// ── LIST ──────────────────────────────────────────
alice.send("LIST")
aliceList := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 323 ")
})
assertContains(
t, aliceList, " 322 ",
"RPL_LIST entry",
)
assertContains(
t, aliceList, "#integration",
"LIST includes #integration",
)
assertContains(
t, aliceList, " 323 ", //nolint:misspell // IRC RPL_LISTEND
"RPL_LISTEND", //nolint:misspell // IRC term
)
// ── WHO ───────────────────────────────────────────
bob.send("WHO #integration")
bobWho := bob.readUntil(func(l string) bool {
return strings.Contains(l, " 315 ")
})
assertContains(
t, bobWho, " 352 ",
"RPL_WHOREPLY",
)
assertContains(
t, bobWho, " 315 ",
"RPL_ENDOFWHO",
)
// ── WHOIS ─────────────────────────────────────────
alice.send("WHOIS bob")
aliceWhois := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 318 ")
})
assertContains(
t, aliceWhois, " 311 ",
"RPL_WHOISUSER",
)
assertContains(
t, aliceWhois, " 312 ",
"RPL_WHOISSERVER",
)
assertContains(
t, aliceWhois, " 318 ",
"RPL_ENDOFWHOIS",
)
// ── WHOIS with channels ───────────────────────────
bob.send("WHOIS alice")
bobWhois := bob.readUntil(func(l string) bool {
return strings.Contains(l, " 318 ")
})
assertContains(
t, bobWhois, " 319 ",
"RPL_WHOISCHANNELS",
)
assertContains(
t, bobWhois, "#integration",
"WHOIS shows #integration channel",
)
// ── LUSERS ────────────────────────────────────────
alice.send("LUSERS")
aliceLusers := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 255 ")
})
assertContains(
t, aliceLusers, " 251 ",
"RPL_LUSERCLIENT",
)
assertContains(
t, aliceLusers, " 255 ",
"RPL_LUSERME",
)
// ── NICK change ───────────────────────────────────
bob.send("NICK bobby")
bobNick := bob.readUntil(func(l string) bool {
return strings.Contains(l, "NICK") &&
strings.Contains(l, "bobby")
})
assertContains(
t, bobNick, "bobby",
"bob sees NICK change to bobby",
)
// alice should see the nick change relayed.
aliceNick := alice.readUntil(func(l string) bool {
return strings.Contains(l, "bobby")
})
assertContains(
t, aliceNick, "NICK",
"alice sees NICK command",
)
assertContains(
t, aliceNick, "bobby",
"alice sees new nick bobby",
)
// Change it back for remaining tests.
bob.send("NICK bob")
bob.readUntil(func(l string) bool {
return strings.Contains(l, "bob")
})
alice.readUntil(func(l string) bool {
return strings.Contains(l, "NICK") &&
strings.Contains(l, "bob")
})
// ── Duplicate NICK ────────────────────────────────
bob.send("NICK alice")
bobDupNick := bob.readUntil(func(l string) bool {
return strings.Contains(l, " 433 ")
})
assertContains(
t, bobDupNick, " 433 ",
"ERR_NICKNAMEINUSE",
)
// ── KICK ──────────────────────────────────────────
// alice is op; she kicks bob.
alice.send("KICK #integration bob :testing kick")
aliceKick := alice.readUntil(func(l string) bool {
return strings.Contains(l, "KICK")
})
assertContains(
t, aliceKick, "KICK",
"alice sees KICK echo",
)
assertContains(
t, aliceKick, "bob",
"KICK mentions bob",
)
bobKick := bob.readUntil(func(l string) bool {
return strings.Contains(l, "KICK")
})
assertContains(
t, bobKick, "KICK",
"bob receives KICK",
)
assertContains(
t, bobKick, "testing kick",
"KICK reason is relayed",
)
// bob rejoins.
bob.joinAndDrain("#integration")
// Drain alice's view of the rejoin.
alice.readUntil(func(l string) bool {
return strings.Contains(l, "JOIN") &&
strings.Contains(l, "bob")
})
// ── KICK non-op should fail ───────────────────────
bob.send("KICK #integration alice :nope")
bobKickFail := bob.readUntil(func(l string) bool {
return strings.Contains(l, " 482 ")
})
assertContains(
t, bobKickFail, " 482 ",
"ERR_CHANOPRIVSNEEDED",
)
// ── TOPIC lock (+t default) ───────────────────────
// +t is default, so bob should not be able to set
// topic.
bob.send("TOPIC #integration :bob tries topic")
bobTopicFail := bob.readUntil(func(l string) bool {
return strings.Contains(l, " 482 ")
})
assertContains(
t, bobTopicFail, " 482 ",
"ERR_CHANOPRIVSNEEDED for topic",
)
// ── PING / PONG ───────────────────────────────────
alice.send("PING :testtoken")
alicePong := alice.readUntil(func(l string) bool {
return strings.Contains(l, "PONG")
})
assertContains(
t, alicePong, "PONG",
"PONG response received",
)
// ── Unknown command ───────────────────────────────
bob.send("FOOBAR")
bobUnknown := bob.readUntil(func(l string) bool {
return strings.Contains(l, " 421 ")
})
assertContains(
t, bobUnknown, " 421 ",
"ERR_UNKNOWNCOMMAND",
)
// ── MOTD ──────────────────────────────────────────
alice.send("MOTD")
aliceMOTD := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 376 ")
})
assertContains(
t, aliceMOTD, " 376 ",
"RPL_ENDOFMOTD",
)
// ── AWAY (set, check via DM, clear) ───────────────
alice.send("AWAY :gone fishing")
aliceAway := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 306 ")
})
assertContains(
t, aliceAway, " 306 ",
"RPL_NOWAWAY",
)
// bob DMs alice — should get RPL_AWAY.
bob.send("PRIVMSG alice :are you there?")
bobAwayReply := bob.readUntil(func(l string) bool {
return strings.Contains(l, " 301 ")
})
assertContains(
t, bobAwayReply, " 301 ",
"RPL_AWAY for bob when messaging alice",
)
assertContains(
t, bobAwayReply, "gone fishing",
"away message relayed",
)
// Clear away.
alice.send("AWAY")
alice.readUntil(func(l string) bool {
return strings.Contains(l, " 305 ")
})
// ── PASS (set password post-registration) ─────────
alice.send("PASS :mypassword123")
alicePass := alice.readUntil(func(l string) bool {
return strings.Contains(l, "Password set")
})
assertContains(
t, alicePass, "Password set",
"password set confirmation",
)
// ── MODE -t/+t topic lock toggle ──────────────────
alice.send("MODE #integration -t")
alice.readUntil(func(l string) bool {
return strings.Contains(l, "-t")
})
bob.readUntil(func(l string) bool {
return strings.Contains(l, "-t")
})
// Now bob should be able to set topic.
bob.send("TOPIC #integration :bob sets topic now")
bobTopicOK := bob.readUntil(func(l string) bool {
return strings.Contains(l, "bob sets topic now")
})
assertContains(
t, bobTopicOK, "bob sets topic now",
"bob can set topic after -t",
)
// alice sees the topic change.
aliceTopicRelay := alice.readUntil(func(l string) bool {
return strings.Contains(l, "bob sets topic now")
})
assertContains(
t, aliceTopicRelay, "bob sets topic now",
"alice sees bob's topic after -t",
)
// Restore +t.
alice.send("MODE #integration +t")
alice.readUntil(func(l string) bool {
return strings.Contains(l, "+t")
})
bob.readUntil(func(l string) bool {
return strings.Contains(l, "+t")
})
// ── DM to nonexistent nick ────────────────────────
alice.send("PRIVMSG nobody123 :hello")
aliceNoSuch := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 401 ")
})
assertContains(
t, aliceNoSuch, " 401 ",
"ERR_NOSUCHNICK",
)
// ── PART with reason ──────────────────────────────
bob.send("PART #integration :bye for now")
bobPart := bob.readUntil(func(l string) bool {
return strings.Contains(l, "PART")
})
assertContains(
t, bobPart, "PART",
"bob sees PART echo",
)
// alice sees bob PART via relay.
alicePart := alice.readUntil(func(l string) bool {
return strings.Contains(l, "PART") &&
strings.Contains(l, "bob")
})
assertContains(
t, alicePart, "bob",
"alice sees bob's PART",
)
assertContains(
t, alicePart, "bye for now",
"PART reason is relayed",
)
// bob rejoins for remaining tests.
bob.joinAndDrain("#integration")
alice.readUntil(func(l string) bool {
return strings.Contains(l, "JOIN") &&
strings.Contains(l, "bob")
})
// ── PART non-existent channel ─────────────────────
bob.send("PART #nonexistent")
bobPartFail := bob.readUntil(func(l string) bool {
return strings.Contains(l, " 403 ") ||
strings.Contains(l, " 442 ")
})
foundPartErr := false
for _, line := range bobPartFail {
if strings.Contains(line, " 403 ") ||
strings.Contains(line, " 442 ") {
foundPartErr = true
break
}
}
if !foundPartErr {
t.Error(
"expected ERR_NOSUCHCHANNEL or " +
"ERR_NOTONCHANNEL",
)
}
// ── User MODE query ───────────────────────────────
alice.send("MODE alice")
aliceUMode := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 221 ")
})
assertContains(
t, aliceUMode, " 221 ",
"RPL_UMODEIS",
)
// ── Multiple channel operation ────────────────────
alice.send("JOIN #second")
alice.readUntil(func(l string) bool {
return strings.Contains(l, " 366 ")
})
bob.send("JOIN #second")
bob.readUntil(func(l string) bool {
return strings.Contains(l, " 366 ")
})
// Drain alice seeing bob join.
alice.readUntil(func(l string) bool {
return strings.Contains(l, "JOIN") &&
strings.Contains(l, "bob")
})
alice.send("PRIVMSG #second :cross-channel test")
bobCross := bob.readUntil(func(l string) bool {
return strings.Contains(l, "cross-channel test")
})
assertContains(
t, bobCross, "cross-channel test",
"bob receives message in #second",
)
// Clean up #second.
alice.send("PART #second")
alice.readUntil(func(l string) bool {
return strings.Contains(l, "PART")
})
bob.send("PART #second")
bob.readUntil(func(l string) bool {
return strings.Contains(l, "PART")
})
// ── QUIT ──────────────────────────────────────────
bob.send("QUIT :integration test done")
bobQuit := bob.readUntil(func(l string) bool {
return strings.Contains(l, "ERROR")
})
assertContains(
t, bobQuit, "integration test done",
"QUIT reason echoed",
)
// alice should see bob's QUIT via relay.
aliceQuit := alice.readUntil(func(l string) bool {
return strings.Contains(l, "QUIT") &&
strings.Contains(l, "bob")
})
assertContains(
t, aliceQuit, "bob",
"alice sees bob's QUIT",
)
}
// TestIntegrationModeSecret tests +s (secret) channel
// mode — verifies that +s can be set and the mode is
// reflected in MODE queries.
func TestIntegrationModeSecret(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#secretroom")
// Set +s.
alice.send("MODE #secretroom +s")
aliceLines := alice.readUntil(func(l string) bool {
return strings.Contains(l, "+s")
})
assertContains(
t, aliceLines, "+s",
"alice sees MODE +s confirmation",
)
// Verify mode is reflected in query.
alice.send("MODE #secretroom")
modeLines := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 324 ")
})
assertContains(
t, modeLines, "s",
"channel mode includes s",
)
}
// TestIntegrationModeModerated tests +m (moderated) mode
// — non-voiced users cannot send.
func TestIntegrationModeModerated(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
bob := env.dial(t)
bob.register("bob")
alice.joinAndDrain("#modtest")
bob.joinAndDrain("#modtest")
// Drain alice's view of bob's join.
alice.readUntil(func(l string) bool {
return strings.Contains(l, "JOIN") &&
strings.Contains(l, "bob")
})
// Set +m.
alice.send("MODE #modtest +m")
alice.readUntil(func(l string) bool {
return strings.Contains(l, "+m")
})
bob.readUntil(func(l string) bool {
return strings.Contains(l, "+m")
})
// bob should get an error trying to send.
bob.send("PRIVMSG #modtest :should fail")
bobLines := bob.readUntil(func(l string) bool {
return strings.Contains(l, " 404 ") ||
strings.Contains(l, " 482 ")
})
foundModErr := false
for _, line := range bobLines {
if strings.Contains(line, " 404 ") ||
strings.Contains(line, " 482 ") {
foundModErr = true
break
}
}
if !foundModErr {
t.Error(
"non-voiced user should not be able to send " +
"in +m channel",
)
}
// Grant +v to bob, then he should be able to send.
alice.send("MODE #modtest +v bob")
alice.readUntil(func(l string) bool {
return strings.Contains(l, "+v")
})
bob.readUntil(func(l string) bool {
return strings.Contains(l, "+v")
})
bob.send("PRIVMSG #modtest :voiced message")
aliceLines := alice.readUntil(func(l string) bool {
return strings.Contains(l, "voiced message")
})
assertContains(
t, aliceLines, "voiced message",
"alice receives voiced bob's message",
)
}
// TestIntegrationThirdClientObserver verifies that a third
// client observing the same channel receives messages from
// the other two.
func TestIntegrationThirdClientObserver(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
bob := env.dial(t)
bob.register("bob")
carol := env.dial(t)
carol.register("carol")
alice.joinAndDrain("#trio")
bob.joinAndDrain("#trio")
carol.joinAndDrain("#trio")
// Drain join notifications.
time.Sleep(100 * time.Millisecond)
// alice sends; both bob and carol should receive.
alice.send("PRIVMSG #trio :hello trio")
bobLines := bob.readUntil(func(l string) bool {
return strings.Contains(l, "hello trio")
})
assertContains(
t, bobLines, "hello trio",
"bob receives trio message",
)
carolLines := carol.readUntil(func(l string) bool {
return strings.Contains(l, "hello trio")
})
assertContains(
t, carolLines, "hello trio",
"carol receives trio message",
)
}
+1 -1
View File
@@ -3,7 +3,7 @@ package ircserver_test
import (
"testing"
"git.eeqj.de/sneak/neoirc/internal/ircserver"
"sneak.berlin/go/neoirc/internal/ircserver"
)
//nolint:funlen // table-driven test
+42 -10
View File
@@ -6,8 +6,8 @@ import (
"strings"
"time"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/pkg/irc"
)
// relayMessages polls the client output queue and delivers
@@ -128,7 +128,11 @@ func (c *Conn) deliverIRCMessage(
default:
// Unknown command — deliver as server notice.
if text != "" {
c.sendFromServer("NOTICE", c.nick, text)
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
c.sendFromServer("NOTICE", nick, text)
}
}
}
@@ -158,8 +162,12 @@ func (c *Conn) deliverNumeric(
_ = json.Unmarshal(msg.Params, &params)
}
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
allParams := make([]string, 0, 1+len(params)+1)
allParams = append(allParams, c.nick)
allParams = append(allParams, nick)
allParams = append(allParams, params...)
if text != "" {
@@ -177,8 +185,12 @@ func (c *Conn) deliverTextMessage(
from := msg.From
target := msg.To
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
// Don't echo our own messages back.
if strings.EqualFold(from, c.nick) {
if strings.EqualFold(from, nick) {
return
}
@@ -192,9 +204,13 @@ func (c *Conn) deliverTextMessage(
// 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 strings.EqualFold(msg.From, c.nick) {
if strings.EqualFold(msg.From, nick) {
return
}
@@ -206,7 +222,11 @@ func (c *Conn) deliverJoin(msg *db.IRCMessage) {
// deliverPart sends a PART notification.
func (c *Conn) deliverPart(msg *db.IRCMessage, text string) {
if strings.EqualFold(msg.From, c.nick) {
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
if strings.EqualFold(msg.From, nick) {
return
}
@@ -227,7 +247,11 @@ func (c *Conn) deliverNickChange(
msg *db.IRCMessage,
newNick string,
) {
if strings.EqualFold(msg.From, c.nick) {
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
if strings.EqualFold(msg.From, nick) {
return
}
@@ -241,7 +265,11 @@ func (c *Conn) deliverQuitMsg(
msg *db.IRCMessage,
text string,
) {
if strings.EqualFold(msg.From, c.nick) {
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
if strings.EqualFold(msg.From, nick) {
return
}
@@ -302,7 +330,11 @@ func (c *Conn) deliverInviteMsg(
_ *db.IRCMessage,
text string,
) {
c.sendFromServer("NOTICE", c.nick, text)
c.mu.Lock()
nick := c.nick
c.mu.Unlock()
c.sendFromServer("NOTICE", nick, text)
}
// deliverMode sends a MODE change notification.
+5 -5
View File
@@ -7,12 +7,12 @@ import (
"net"
"sync"
"git.eeqj.de/sneak/neoirc/internal/broker"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/service"
"go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/service"
)
// Params defines the dependencies for creating an IRC
+4 -4
View File
@@ -11,10 +11,10 @@ import (
"testing"
"time"
"git.eeqj.de/sneak/neoirc/internal/broker"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/ircserver"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/ircserver"
_ "modernc.org/sqlite"
)
+1 -1
View File
@@ -5,8 +5,8 @@ import (
"log/slog"
"os"
"git.eeqj.de/sneak/neoirc/internal/globals"
"go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/globals"
)
// Params defines the dependencies for creating a Logger.
+3 -3
View File
@@ -7,9 +7,6 @@ import (
"net/http"
"time"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/logger"
basicauth "github.com/99designs/basicauth-go"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
@@ -18,6 +15,9 @@ import (
"github.com/slok/go-http-metrics/middleware/std"
"github.com/spf13/viper"
"go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/logger"
)
const corsMaxAge = 300
+1 -1
View File
@@ -3,7 +3,7 @@ package ratelimit_test
import (
"testing"
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
"sneak.berlin/go/neoirc/internal/ratelimit"
)
func TestNewCreatesLimiter(t *testing.T) {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"net/http"
"time"
"git.eeqj.de/sneak/neoirc/web"
"sneak.berlin/go/neoirc/web"
sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/go-chi/chi/v5"
+13 -15
View File
@@ -12,12 +12,12 @@ import (
"syscall"
"time"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/handlers"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/middleware"
"go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/handlers"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/middleware"
"github.com/getsentry/sentry-go"
"github.com/go-chi/chi/v5"
@@ -71,7 +71,14 @@ func New(
lifecycle.Append(fx.Hook{
OnStart: func(_ context.Context) error {
srv.startupTime = time.Now()
go srv.Run() //nolint:contextcheck
// Build the router before OnStart returns, so srv can
// handle requests as soon as the app has started.
srv.configure()
srv.enableSentry()
srv.SetupRoutes()
go srv.serve() //nolint:contextcheck
return nil
},
@@ -83,13 +90,6 @@ func New(
return srv, nil
}
// Run starts the server configuration, Sentry, and begins serving.
func (srv *Server) Run() {
srv.configure()
srv.enableSentry()
srv.serve()
}
// ServeHTTP delegates to the chi router.
func (srv *Server) ServeHTTP(
writer http.ResponseWriter,
@@ -202,8 +202,6 @@ func (srv *Server) serveUntilShutdown() {
Handler: srv,
}
srv.SetupRoutes()
srv.log.Info(
"http begin listen", "listenaddr", listenAddr,
)
+5 -5
View File
@@ -11,12 +11,12 @@ import (
"strconv"
"strings"
"git.eeqj.de/sneak/neoirc/internal/broker"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/pkg/irc"
)
// Params defines the dependencies for creating a Service.
+7 -7
View File
@@ -12,16 +12,16 @@ import (
"os"
"testing"
"git.eeqj.de/sneak/neoirc/internal/broker"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/service"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
"golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/pkg/irc"
)
func TestMain(m *testing.M) {
+1 -1
View File
@@ -3,7 +3,7 @@ package stats_test
import (
"testing"
"git.eeqj.de/sneak/neoirc/internal/stats"
"sneak.berlin/go/neoirc/internal/stats"
)
func TestNew(t *testing.T) {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"errors"
"testing"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"sneak.berlin/go/neoirc/pkg/irc"
)
func TestName(t *testing.T) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/JOIN.json",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/JOIN.json",
"title": "JOIN",
"description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.",
"$ref": "../message.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/KICK.json",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/KICK.json",
"title": "KICK",
"description": "Kick a user from a channel. RFC 1459 §4.2.8.",
"$ref": "../message.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/MODE.json",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/MODE.json",
"title": "MODE",
"description": "Set or query channel/user modes. RFC 1459 §4.2.3.",
"$ref": "../message.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/NICK.json",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/NICK.json",
"title": "NICK",
"description": "Change nickname. C2S: request new nick. S2C: notification of nick change. RFC 1459 §4.1.2.",
"$ref": "../message.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/NOTICE.json",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/NOTICE.json",
"title": "NOTICE",
"description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.",
"$ref": "../message.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PART.json",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PART.json",
"title": "PART",
"description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.",
"$ref": "../message.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PING.json",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PING.json",
"title": "PING",
"description": "Keepalive. C2S or S2S. Server responds with PONG. RFC 1459 §4.6.2.",
"$ref": "../message.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PONG.json",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PONG.json",
"title": "PONG",
"description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.",
"$ref": "../message.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PRIVMSG.json",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PRIVMSG.json",
"title": "PRIVMSG",
"description": "Send a message to a channel or user. C2S: client sends to server. S2C: server relays to recipients. RFC 1459 §4.4.1.",
"$ref": "../message.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PUBKEY.json",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PUBKEY.json",
"title": "PUBKEY",
"description": "Announce or relay a user's public signing key. C2S: client announces key to channel or server. S2C: server relays to channel members. Protocol extension (not in RFC 1459). Body is a structured object (not an array) containing the key material.",
"$ref": "../message.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/QUIT.json",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/QUIT.json",
"title": "QUIT",
"description": "User disconnected. S2C only. RFC 1459 §4.1.6.",
"$ref": "../message.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/TOPIC.json",
"$id": "https://sneak.berlin/go/neoirc/schema/commands/TOPIC.json",
"title": "TOPIC",
"description": "Get or set channel topic. C2S: set topic (body present) or query (body absent). S2C: topic change notification. RFC 1459 §4.2.4.",
"$ref": "../message.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/message.json",
"$id": "https://sneak.berlin/go/neoirc/schema/message.json",
"title": "IRC Message Envelope",
"description": "Base envelope for all messages. Mirrors IRC wire format (RFC 1459/2812) encoded as JSON over HTTP. The 'command' field carries either an IRC command name (PRIVMSG, JOIN, etc.) or a three-digit numeric reply code (001, 353, 433, etc.).",
"type": "object",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/001.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/001.json",
"title": "001 RPL_WELCOME",
"description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.",
"$ref": "../message.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/002.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/002.json",
"title": "002 RPL_YOURHOST",
"description": "Server host info sent after session creation. RFC 2812 \u00a75.1.",
"$ref": "../message.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/003.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/003.json",
"title": "003 RPL_CREATED",
"description": "Server creation date. RFC 2812 \u00a75.1.",
"$ref": "../message.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/004.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/004.json",
"title": "004 RPL_MYINFO",
"description": "Server info (name, version, available modes). RFC 2812 \u00a75.1.",
"$ref": "../message.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/322.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/322.json",
"title": "322 RPL_LIST",
"description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.",
"$ref": "../message.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/323.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/323.json",
"title": "323 RPL_LISTEND",
"description": "End of channel list. RFC 1459 \u00a76.2.",
"$ref": "../message.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/332.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/332.json",
"title": "332 RPL_TOPIC",
"description": "Channel topic (sent on JOIN or TOPIC query). RFC 1459 \u00a76.2.",
"$ref": "../message.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/353.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/353.json",
"title": "353 RPL_NAMREPLY",
"description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.",
"$ref": "../message.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/366.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/366.json",
"title": "366 RPL_ENDOFNAMES",
"description": "End of NAMES list. RFC 1459 \u00a76.2.",
"$ref": "../message.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/372.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/372.json",
"title": "372 RPL_MOTD",
"description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.",
"$ref": "../message.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/375.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/375.json",
"title": "375 RPL_MOTDSTART",
"description": "Start of MOTD. RFC 2812 \u00a75.1.",
"$ref": "../message.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/376.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/376.json",
"title": "376 RPL_ENDOFMOTD",
"description": "End of MOTD. RFC 2812 \u00a75.1.",
"$ref": "../message.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/401.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/401.json",
"title": "401 ERR_NOSUCHNICK",
"description": "No such nick/channel. RFC 1459 \u00a76.1.",
"$ref": "../message.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/403.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/403.json",
"title": "403 ERR_NOSUCHCHANNEL",
"description": "No such channel. RFC 1459 \u00a76.1.",
"$ref": "../message.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/433.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/433.json",
"title": "433 ERR_NICKNAMEINUSE",
"description": "Nickname is already in use. RFC 1459 \u00a76.1.",
"$ref": "../message.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/442.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/442.json",
"title": "442 ERR_NOTONCHANNEL",
"description": "You're not on that channel. RFC 1459 \u00a76.1.",
"$ref": "../message.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/482.json",
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/482.json",
"title": "482 ERR_CHANOPRIVSNEEDED",
"description": "You're not channel operator. RFC 1459 \u00a76.1.",
"$ref": "../message.json",