5 Commits

Author SHA1 Message Date
clawbot
abe0cc2c30 refactor: unify user mode processing into shared service layer
Some checks failed
check / check (push) Failing after 2m28s
Both the HTTP API and IRC wire protocol handlers now call
service.ApplyUserMode/service.QueryUserMode for all user
mode operations. The service layer iterates mode strings
character by character (the correct IRC approach), ensuring
identical behavior regardless of transport.

Removed duplicate mode logic from internal/handlers/utility.go
(buildUserModeString, applyUserModeChange, applyModeChar) and
internal/ircserver/commands.go (buildUmodeString, inline iteration).

Added service-level tests for QueryUserMode, ApplyUserMode
(single-char, multi-char, invalid input, de-oper, +o rejection).
2026-04-02 06:48:55 -07:00
clawbot
327ff37059 fix: address review findings — dynamic version, deduplicate KILL, update README
All checks were successful
check / check (push) Successful in 1m3s
2026-04-01 14:44:27 -07:00
clawbot
17479c4f44 fix: rebase onto main, add IRC wire handlers and integration tests for Tier 3 commands
Some checks failed
check / check (push) Failing after 2m10s
Rebase onto main to resolve conflicts from module path rename
(sneak.berlin/go/neoirc) and integration test addition.

- Update import paths in utility.go to new module path
- Add IRC wire protocol handlers for VERSION, ADMIN, INFO,
  TIME, KILL, and WALLOPS to ircserver/commands.go
- Register all 6 new commands in the IRC command dispatch map
- Implement proper user MODE +w/-w support for WALLOPS
- Add WALLOPS relay delivery in relay.go
- Add integration tests for all 7 Tier 3 commands:
  USERHOST, VERSION, ADMIN, INFO, TIME, KILL, WALLOPS
- Add newTestEnvWithOper helper for oper-dependent tests
2026-04-01 14:16:09 -07:00
user
9c4ec966fb feat: implement Tier 3 utility IRC commands
Implement all 7 utility IRC commands from issue #87:

User commands:
- USERHOST: quick lookup of user@host for up to 5 nicks (RPL 302)
- VERSION: server version string using globals.Version (RPL 351)
- ADMIN: server admin contact info (RPL 256-259)
- INFO: server software info text (RPL 371/374)
- TIME: server local time in RFC format (RPL 391)

Oper commands:
- KILL: forcibly disconnect a user (requires is_oper), broadcasts
  QUIT to all shared channels, cleans up sessions
- WALLOPS: broadcast message to all users with +w usermode
  (requires is_oper)

Supporting changes:
- Add is_wallops column to sessions table in 001_initial.sql
- Add user mode +w tracking via MODE nick +w/-w
- User mode queries now return actual modes (+o, +w)
- MODE -o allows de-opering yourself; MODE +o rejected
- MODE for other users returns ERR_USERSDONTMATCH (502)
- Extract dispatch helpers to reduce dispatchCommand complexity

Tests cover all commands including error cases, oper checks,
user mode set/unset, KILL broadcast, WALLOPS delivery, and
edge cases (self-kill, nonexistent users, missing params).

closes #87
2026-04-01 14:07:22 -07:00
f829f9e3da Change module path to sneak.berlin/go/neoirc (#99)
Some checks failed
check / check (push) Failing after 1m21s
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
71 changed files with 2777 additions and 198 deletions

View File

@@ -2307,8 +2307,8 @@ IRC_LISTEN_ADDR=
| Connection | `NICK`, `USER`, `PASS`, `QUIT`, `PING`/`PONG`, `CAP` | | Connection | `NICK`, `USER`, `PASS`, `QUIT`, `PING`/`PONG`, `CAP` |
| Channels | `JOIN`, `PART`, `MODE`, `TOPIC`, `NAMES`, `LIST`, `KICK`, `INVITE` | | Channels | `JOIN`, `PART`, `MODE`, `TOPIC`, `NAMES`, `LIST`, `KICK`, `INVITE` |
| Messaging | `PRIVMSG`, `NOTICE` | | Messaging | `PRIVMSG`, `NOTICE` |
| Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY` | | Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY`, `USERHOST`, `VERSION`, `ADMIN`, `INFO`, `TIME` |
| Operator | `OPER` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) | | Operator | `OPER`, `KILL`, `WALLOPS` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) |
### Protocol Details ### Protocol Details
@@ -2820,6 +2820,10 @@ guess is borne by the server (bcrypt), not the client.
login from additional devices via `POST /api/v1/login` login from additional devices via `POST /api/v1/login`
- [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for - [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for
all API authentication all API authentication
- [x] **Tier 3 utility commands** — USERHOST (302), VERSION (351), ADMIN
(256259), INFO (371/374), TIME (391), KILL (oper-only forced
disconnect), WALLOPS (oper-only broadcast to +w users)
- [x] **User mode +w** — wallops usermode via `MODE nick +w/-w`
### Future (1.0+) ### Future (1.0+)

View File

@@ -1,7 +1,7 @@
// Package main is the entry point for the neoirc-cli client. // Package main is the entry point for the neoirc-cli client.
package main package main
import "git.eeqj.de/sneak/neoirc/internal/cli" import "sneak.berlin/go/neoirc/internal/cli"
func main() { func main() {
cli.Run() cli.Run()

View File

@@ -2,19 +2,19 @@
package main package main
import ( 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" "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 ( var (

2
go.mod
View File

@@ -1,4 +1,4 @@
module git.eeqj.de/sneak/neoirc module sneak.berlin/go/neoirc
go 1.24.0 go 1.24.0

View File

@@ -5,7 +5,7 @@ import (
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/broker"
) )
func TestNewBroker(t *testing.T) { func TestNewBroker(t *testing.T) {

View File

@@ -15,7 +15,7 @@ import (
"strings" "strings"
"time" "time"
"git.eeqj.de/sneak/neoirc/pkg/irc" "sneak.berlin/go/neoirc/pkg/irc"
) )
const ( const (

View File

@@ -8,7 +8,7 @@ import (
"math/big" "math/big"
"time" "time"
"git.eeqj.de/sneak/neoirc/internal/hashcash" "sneak.berlin/go/neoirc/internal/hashcash"
) )
const ( const (

View File

@@ -8,8 +8,8 @@ import (
"sync" "sync"
"time" "time"
api "git.eeqj.de/sneak/neoirc/internal/cli/api" api "sneak.berlin/go/neoirc/internal/cli/api"
"git.eeqj.de/sneak/neoirc/pkg/irc" "sneak.berlin/go/neoirc/pkg/irc"
) )
const ( const (

View File

@@ -5,10 +5,10 @@ import (
"errors" "errors"
"log/slog" "log/slog"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/logger"
"github.com/spf13/viper" "github.com/spf13/viper"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/logger"
_ "github.com/joho/godotenv/autoload" // loads .env file _ "github.com/joho/godotenv/autoload" // loads .env file
) )

View File

@@ -12,9 +12,9 @@ import (
"strconv" "strconv"
"strings" "strings"
"git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/logger"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/logger"
_ "github.com/joho/godotenv/autoload" // .env _ "github.com/joho/godotenv/autoload" // .env
_ "modernc.org/sqlite" // driver _ "modernc.org/sqlite" // driver

View File

@@ -4,8 +4,8 @@ import (
"os" "os"
"testing" "testing"
"git.eeqj.de/sneak/neoirc/internal/db"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"sneak.berlin/go/neoirc/internal/db"
) )
func TestMain(m *testing.M) { func TestMain(m *testing.M) {

View File

@@ -12,8 +12,8 @@ import (
"strings" "strings"
"time" "time"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"github.com/google/uuid" "github.com/google/uuid"
"sneak.berlin/go/neoirc/pkg/irc"
) )
const ( const (
@@ -2413,3 +2413,132 @@ func (database *Database) SetChannelUserLimit(
return nil return nil
} }
// SetSessionWallops sets the wallops (+w) flag on a
// session.
func (database *Database) SetSessionWallops(
ctx context.Context,
sessionID int64,
enabled bool,
) error {
val := 0
if enabled {
val = 1
}
_, err := database.conn.ExecContext(
ctx,
`UPDATE sessions SET is_wallops = ? WHERE id = ?`,
val, sessionID,
)
if err != nil {
return fmt.Errorf("set session wallops: %w", err)
}
return nil
}
// IsSessionWallops returns whether the session has the
// wallops (+w) usermode set.
func (database *Database) IsSessionWallops(
ctx context.Context,
sessionID int64,
) (bool, error) {
var isWallops int
err := database.conn.QueryRowContext(
ctx,
`SELECT is_wallops FROM sessions WHERE id = ?`,
sessionID,
).Scan(&isWallops)
if err != nil {
return false, fmt.Errorf(
"check session wallops: %w", err,
)
}
return isWallops != 0, nil
}
// GetWallopsSessionIDs returns all session IDs that have
// the wallops (+w) usermode set.
func (database *Database) GetWallopsSessionIDs(
ctx context.Context,
) ([]int64, error) {
rows, err := database.conn.QueryContext(
ctx,
`SELECT id FROM sessions WHERE is_wallops = 1`,
)
if err != nil {
return nil, fmt.Errorf(
"get wallops sessions: %w", err,
)
}
defer func() { _ = rows.Close() }()
var ids []int64
for rows.Next() {
var sessionID int64
if scanErr := rows.Scan(&sessionID); scanErr != nil {
return nil, fmt.Errorf(
"scan wallops session: %w", scanErr,
)
}
ids = append(ids, sessionID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf(
"iterate wallops sessions: %w", err,
)
}
return ids, nil
}
// UserhostInfo holds the data needed for RPL_USERHOST.
type UserhostInfo struct {
Nick string
Username string
Hostname string
IsOper bool
AwayMessage string
}
// GetUserhostInfo returns USERHOST info for the given
// nicks. Only nicks that exist are returned.
func (database *Database) GetUserhostInfo(
ctx context.Context,
nicks []string,
) ([]UserhostInfo, error) {
if len(nicks) == 0 {
return nil, nil
}
results := make([]UserhostInfo, 0, len(nicks))
for _, nick := range nicks {
var info UserhostInfo
err := database.conn.QueryRowContext(
ctx,
`SELECT nick, username, hostname,
is_oper, away_message
FROM sessions WHERE nick = ?`,
nick,
).Scan(
&info.Nick, &info.Username, &info.Hostname,
&info.IsOper, &info.AwayMessage,
)
if err != nil {
continue // nick not found, skip
}
results = append(results, info)
}
return results, nil
}

View File

@@ -4,7 +4,7 @@ import (
"encoding/json" "encoding/json"
"testing" "testing"
"git.eeqj.de/sneak/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )

View File

@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS sessions (
hostname TEXT NOT NULL DEFAULT '', hostname TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '', ip TEXT NOT NULL DEFAULT '',
is_oper INTEGER NOT NULL DEFAULT 0, is_oper INTEGER NOT NULL DEFAULT 0,
is_wallops INTEGER NOT NULL DEFAULT 0,
password_hash TEXT NOT NULL DEFAULT '', password_hash TEXT NOT NULL DEFAULT '',
signing_key TEXT NOT NULL DEFAULT '', signing_key TEXT NOT NULL DEFAULT '',
away_message TEXT NOT NULL DEFAULT '', away_message TEXT NOT NULL DEFAULT '',

View File

@@ -12,11 +12,11 @@ import (
"strings" "strings"
"time" "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" "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 ( var (
@@ -969,10 +969,12 @@ func (hdlr *Handlers) dispatchCommand(
bodyLines func() []string, bodyLines func() []string,
) { ) {
switch command { switch command {
case irc.CmdAway: case irc.CmdAway, irc.CmdNick,
hdlr.handleAway( irc.CmdPass, irc.CmdInvite:
hdlr.dispatchBodyOnlyCommand(
writer, request, writer, request,
sessionID, clientID, nick, bodyLines, sessionID, clientID, nick,
command, bodyLines,
) )
case irc.CmdPrivmsg, irc.CmdNotice: case irc.CmdPrivmsg, irc.CmdNotice:
hdlr.handlePrivmsg( hdlr.handlePrivmsg(
@@ -991,27 +993,12 @@ func (hdlr *Handlers) dispatchCommand(
writer, request, writer, request,
sessionID, clientID, nick, target, body, sessionID, clientID, nick, target, body,
) )
case irc.CmdNick:
hdlr.handleNick(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdPass:
hdlr.handlePass(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdTopic: case irc.CmdTopic:
hdlr.handleTopic( hdlr.handleTopic(
writer, request, writer, request,
sessionID, clientID, nick, sessionID, clientID, nick,
target, body, bodyLines, target, body, bodyLines,
) )
case irc.CmdInvite:
hdlr.handleInvite(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdKick: case irc.CmdKick:
hdlr.handleKick( hdlr.handleKick(
writer, request, writer, request,
@@ -1022,12 +1009,15 @@ func (hdlr *Handlers) dispatchCommand(
hdlr.handleQuit( hdlr.handleQuit(
writer, request, sessionID, nick, body, writer, request, sessionID, nick, body,
) )
case irc.CmdOper: case irc.CmdOper, irc.CmdKill, irc.CmdWallops:
hdlr.handleOper( hdlr.dispatchOperCommand(
writer, request, writer, request,
sessionID, clientID, nick, bodyLines, sessionID, clientID, nick,
command, bodyLines,
) )
case irc.CmdMotd, irc.CmdPing: case irc.CmdMotd, irc.CmdPing,
irc.CmdVersion, irc.CmdAdmin,
irc.CmdInfo, irc.CmdTime:
hdlr.dispatchInfoCommand( hdlr.dispatchInfoCommand(
writer, request, writer, request,
sessionID, clientID, nick, sessionID, clientID, nick,
@@ -1082,6 +1072,11 @@ func (hdlr *Handlers) dispatchQueryCommand(
writer, request, writer, request,
sessionID, clientID, nick, sessionID, clientID, nick,
) )
case irc.CmdUserhost:
hdlr.handleUserhost(
writer, request,
sessionID, clientID, nick, bodyLines,
)
default: default:
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
request.Context(), clientID, request.Context(), clientID,
@@ -1874,7 +1869,8 @@ func (hdlr *Handlers) deliverSetTopicNumerics(
} }
// dispatchInfoCommand handles informational IRC commands // dispatchInfoCommand handles informational IRC commands
// that produce server-side numerics (MOTD, PING). // that produce server-side numerics (MOTD, PING,
// VERSION, ADMIN, INFO, TIME).
func (hdlr *Handlers) dispatchInfoCommand( func (hdlr *Handlers) dispatchInfoCommand(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -1900,6 +1896,34 @@ func (hdlr *Handlers) dispatchInfoCommand(
}, },
http.StatusOK) http.StatusOK)
return
case irc.CmdVersion:
hdlr.handleVersion(
writer, request,
sessionID, clientID, nick,
)
return
case irc.CmdAdmin:
hdlr.handleAdmin(
writer, request,
sessionID, clientID, nick,
)
return
case irc.CmdInfo:
hdlr.handleInfo(
writer, request,
sessionID, clientID, nick,
)
return
case irc.CmdTime:
hdlr.handleTime(
writer, request,
sessionID, clientID, nick,
)
return return
} }
@@ -1956,15 +1980,11 @@ func (hdlr *Handlers) handleMode(
channel := target channel := target
if !strings.HasPrefix(channel, "#") { if !strings.HasPrefix(channel, "#") {
// User mode query — return empty modes. hdlr.handleUserMode(
hdlr.enqueueNumeric( writer, request,
request.Context(), clientID, sessionID, clientID, nick, target,
irc.RplUmodeIs, nick, nil, "+", bodyLines,
) )
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
return return
} }

View File

@@ -18,21 +18,21 @@ import (
"testing" "testing"
"time" "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"
"go.uber.org/fx/fxtest" "go.uber.org/fx/fxtest"
"golang.org/x/crypto/bcrypt" "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) { func TestMain(m *testing.M) {

View File

@@ -5,7 +5,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"git.eeqj.de/sneak/neoirc/pkg/irc" "sneak.berlin/go/neoirc/pkg/irc"
) )
const minPasswordLength = 8 const minPasswordLength = 8

View File

@@ -9,17 +9,17 @@ import (
"net/http" "net/http"
"time" "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" "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") var errUnauthorized = errors.New("unauthorized")

View File

@@ -0,0 +1,551 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/pkg/irc"
)
// maxUserhostNicks is the maximum number of nicks allowed
// in a single USERHOST query (RFC 2812).
const maxUserhostNicks = 5
// dispatchBodyOnlyCommand routes commands that take
// (writer, request, sessionID, clientID, nick, bodyLines).
func (hdlr *Handlers) dispatchBodyOnlyCommand(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, command string,
bodyLines func() []string,
) {
switch command {
case irc.CmdAway:
hdlr.handleAway(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdNick:
hdlr.handleNick(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdPass:
hdlr.handlePass(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdInvite:
hdlr.handleInvite(
writer, request,
sessionID, clientID, nick, bodyLines,
)
}
}
// dispatchOperCommand routes oper-related commands (OPER,
// KILL, WALLOPS) to their handlers.
func (hdlr *Handlers) dispatchOperCommand(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, command string,
bodyLines func() []string,
) {
switch command {
case irc.CmdOper:
hdlr.handleOper(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdKill:
hdlr.handleKill(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdWallops:
hdlr.handleWallops(
writer, request,
sessionID, clientID, nick, bodyLines,
)
}
}
// handleUserhost handles the USERHOST command.
// Returns user@host info for up to 5 nicks.
func (hdlr *Handlers) handleUserhost(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
ctx := request.Context()
lines := bodyLines()
if len(lines) == 0 {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdUserhost},
"Not enough parameters",
)
return
}
// Limit to 5 nicks per RFC 2812.
nicks := lines
if len(nicks) > maxUserhostNicks {
nicks = nicks[:maxUserhostNicks]
}
infos, err := hdlr.params.Database.GetUserhostInfo(
ctx, nicks,
)
if err != nil {
hdlr.log.Error(
"userhost query failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
replyStr := hdlr.buildUserhostReply(infos)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplUserHost, nick, nil,
replyStr,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// buildUserhostReply builds the RPL_USERHOST reply
// string per RFC 2812.
func (hdlr *Handlers) buildUserhostReply(
infos []db.UserhostInfo,
) string {
replies := make([]string, 0, len(infos))
for idx := range infos {
info := &infos[idx]
username := info.Username
if username == "" {
username = info.Nick
}
hostname := info.Hostname
if hostname == "" {
hostname = hdlr.serverName()
}
operStar := ""
if info.IsOper {
operStar = "*"
}
awayPrefix := "+"
if info.AwayMessage != "" {
awayPrefix = "-"
}
replies = append(replies,
info.Nick+operStar+"="+
awayPrefix+username+"@"+hostname,
)
}
return strings.Join(replies, " ")
}
// handleVersion handles the VERSION command.
// Returns the server version string.
func (hdlr *Handlers) handleVersion(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
srvName := hdlr.serverName()
version := hdlr.serverVersion()
// 351 RPL_VERSION
hdlr.enqueueNumeric(
ctx, clientID, irc.RplVersion, nick,
[]string{version + ".", srvName},
"",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleAdmin handles the ADMIN command.
// Returns server admin contact info.
func (hdlr *Handlers) handleAdmin(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
srvName := hdlr.serverName()
// 256 RPL_ADMINME
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminMe, nick,
[]string{srvName},
"Administrative info",
)
// 257 RPL_ADMINLOC1
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminLoc1, nick, nil,
"neoirc server",
)
// 258 RPL_ADMINLOC2
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminLoc2, nick, nil,
"IRC over HTTP",
)
// 259 RPL_ADMINEMAIL
hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminEmail, nick, nil,
"admin@"+srvName,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleInfo handles the INFO command.
// Returns server software information.
func (hdlr *Handlers) handleInfo(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
version := hdlr.serverVersion()
infoLines := []string{
"neoirc — IRC semantics over HTTP",
"Version: " + version,
"Written in Go",
"Started: " +
hdlr.params.Globals.StartTime.
Format(time.RFC1123),
}
for _, line := range infoLines {
// 371 RPL_INFO
hdlr.enqueueNumeric(
ctx, clientID, irc.RplInfo, nick, nil,
line,
)
}
// 374 RPL_ENDOFINFO
hdlr.enqueueNumeric(
ctx, clientID, irc.RplEndOfInfo, nick, nil,
"End of /INFO list",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleTime handles the TIME command.
// Returns the server's local time in RFC format.
func (hdlr *Handlers) handleTime(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
) {
ctx := request.Context()
srvName := hdlr.serverName()
// 391 RPL_TIME
hdlr.enqueueNumeric(
ctx, clientID, irc.RplTime, nick,
[]string{srvName},
time.Now().Format(time.RFC1123),
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleKill handles the KILL command.
// Forcibly disconnects a user (oper only).
func (hdlr *Handlers) handleKill(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
ctx := request.Context()
// Check oper status.
isOper, err := hdlr.params.Database.IsSessionOper(
ctx, sessionID,
)
if err != nil || !isOper {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoPrivileges, nick, nil,
"Permission Denied- You're not an IRC operator",
)
return
}
lines := bodyLines()
var targetNick string
if len(lines) > 0 {
targetNick = strings.TrimSpace(lines[0])
}
if targetNick == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdKill},
"Not enough parameters",
)
return
}
reason := "KILLed"
if len(lines) > 1 {
reason = lines[1]
}
targetSID, lookupErr := hdlr.params.Database.
GetSessionByNick(ctx, targetNick)
if lookupErr != nil {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoSuchNick, nick,
[]string{targetNick},
"No such nick/channel",
)
return
}
// Do not allow killing yourself.
if targetSID == sessionID {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrCantKillServer, nick, nil,
"You cannot KILL yourself",
)
return
}
quitReason := "Killed (" + nick + " (" + reason + "))"
hdlr.svc.BroadcastQuit(
request.Context(), targetSID,
targetNick, quitReason,
)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleWallops handles the WALLOPS command.
// Broadcasts a message to all users with +w usermode
// (oper only).
func (hdlr *Handlers) handleWallops(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
ctx := request.Context()
// Check oper status.
isOper, err := hdlr.params.Database.IsSessionOper(
ctx, sessionID,
)
if err != nil || !isOper {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNoPrivileges, nick, nil,
"Permission Denied- You're not an IRC operator",
)
return
}
lines := bodyLines()
if len(lines) == 0 {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdWallops},
"Not enough parameters",
)
return
}
message := strings.Join(lines, " ")
wallopsSIDs, err := hdlr.params.Database.
GetWallopsSessionIDs(ctx)
if err != nil {
hdlr.log.Error(
"get wallops sessions failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
if len(wallopsSIDs) > 0 {
body, mErr := json.Marshal([]string{message})
if mErr != nil {
hdlr.log.Error(
"marshal wallops body", "error", mErr,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
_ = hdlr.fanOutSilent(
request, irc.CmdWallops, nick, "*",
json.RawMessage(body), wallopsSIDs,
)
}
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleUserMode handles user mode queries and changes
// (e.g., MODE nick, MODE nick +w). Delegates to the
// shared service.ApplyUserMode / service.QueryUserMode so
// that mode string processing is identical for both the
// HTTP API and IRC wire protocol.
func (hdlr *Handlers) handleUserMode(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, target string,
bodyLines func() []string,
) {
ctx := request.Context()
lines := bodyLines()
// Mode change requested.
if len(lines) > 0 {
// Users can only change their own modes.
if target != nick && target != "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrUsersDoNotMatch, nick, nil,
"Can't change mode for other users",
)
return
}
newModes, err := hdlr.svc.ApplyUserMode(
ctx, sessionID, lines[0],
)
if err != nil {
var ircErr *service.IRCError
if errors.As(err, &ircErr) {
hdlr.respondIRCError(
writer, request,
clientID, sessionID,
ircErr.Code, nick, ircErr.Params,
ircErr.Message,
)
return
}
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplUmodeIs, nick, nil,
newModes,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
return
}
// Mode query — delegate to shared service.
modeStr := hdlr.svc.QueryUserMode(ctx, sessionID)
hdlr.enqueueNumeric(
ctx, clientID, irc.RplUmodeIs, nick, nil,
modeStr,
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}

View File

@@ -0,0 +1,982 @@
// Tests for Tier 3 utility IRC commands: USERHOST,
// VERSION, ADMIN, INFO, TIME, KILL, WALLOPS.
//
//nolint:paralleltest
package handlers_test
import (
"strings"
"testing"
)
// --- USERHOST ---
func TestUserhostSingleNick(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("alice")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "USERHOST",
bodyKey: []string{"alice"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 302 RPL_USERHOST.
msg := findNumericWithParams(msgs, "302")
if msg == nil {
t.Fatalf(
"expected RPL_USERHOST (302), got %v",
msgs,
)
}
// Body should contain "alice" with the
// nick=+user@host format.
body := getNumericBody(msg)
if !strings.Contains(body, "alice") {
t.Fatalf(
"expected body to contain 'alice', got %q",
body,
)
}
// '+' means not away.
if !strings.Contains(body, "=+") {
t.Fatalf(
"expected not-away prefix '=+', got %q",
body,
)
}
}
func TestUserhostMultipleNicks(t *testing.T) {
tserver := newTestServer(t)
token1 := tserver.createSession("bob")
token2 := tserver.createSession("carol")
_ = token2
_, lastID := tserver.pollMessages(token1, 0)
tserver.sendCommand(token1, map[string]any{
commandKey: "USERHOST",
bodyKey: []string{"bob", "carol"},
})
msgs, _ := tserver.pollMessages(token1, lastID)
msg := findNumericWithParams(msgs, "302")
if msg == nil {
t.Fatalf(
"expected RPL_USERHOST (302), got %v",
msgs,
)
}
body := getNumericBody(msg)
if !strings.Contains(body, "bob") {
t.Fatalf(
"expected body to contain 'bob', got %q",
body,
)
}
if !strings.Contains(body, "carol") {
t.Fatalf(
"expected body to contain 'carol', got %q",
body,
)
}
}
func TestUserhostNonexistentNick(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("dave")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "USERHOST",
bodyKey: []string{"nobody"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Should still get 302 but with empty body.
msg := findNumericWithParams(msgs, "302")
if msg == nil {
t.Fatalf(
"expected RPL_USERHOST (302), got %v",
msgs,
)
}
}
func TestUserhostNoParams(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("eve")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "USERHOST",
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 461 ERR_NEEDMOREPARAMS.
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestUserhostShowsOper(t *testing.T) {
tserver := newTestServerWithOper(t)
token := tserver.createSession("opernick")
_, lastID := tserver.pollMessages(token, 0)
// Authenticate as oper.
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
_, lastID = tserver.pollMessages(token, lastID)
// USERHOST should show '*' for oper.
tserver.sendCommand(token, map[string]any{
commandKey: "USERHOST",
bodyKey: []string{"opernick"},
})
msgs, _ := tserver.pollMessages(token, lastID)
msg := findNumericWithParams(msgs, "302")
if msg == nil {
t.Fatalf(
"expected RPL_USERHOST (302), got %v",
msgs,
)
}
body := getNumericBody(msg)
if !strings.Contains(body, "opernick*=") {
t.Fatalf(
"expected oper '*' in reply, got %q",
body,
)
}
}
func TestUserhostShowsAway(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("awaynick")
_, lastID := tserver.pollMessages(token, 0)
// Set away.
tserver.sendCommand(token, map[string]any{
commandKey: "AWAY",
bodyKey: []string{"gone fishing"},
})
_, lastID = tserver.pollMessages(token, lastID)
// USERHOST should show '-' for away.
tserver.sendCommand(token, map[string]any{
commandKey: "USERHOST",
bodyKey: []string{"awaynick"},
})
msgs, _ := tserver.pollMessages(token, lastID)
msg := findNumericWithParams(msgs, "302")
if msg == nil {
t.Fatalf(
"expected RPL_USERHOST (302), got %v",
msgs,
)
}
body := getNumericBody(msg)
if !strings.Contains(body, "=-") {
t.Fatalf(
"expected away prefix '=-' in reply, got %q",
body,
)
}
}
// --- VERSION ---
func TestVersion(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("frank")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "VERSION",
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 351 RPL_VERSION.
msg := findNumericWithParams(msgs, "351")
if msg == nil {
t.Fatalf(
"expected RPL_VERSION (351), got %v",
msgs,
)
}
params := getNumericParams(msg)
if len(params) == 0 {
t.Fatal("expected VERSION params, got none")
}
// First param should contain version string.
if !strings.Contains(params[0], "test") {
t.Fatalf(
"expected version to contain 'test', got %q",
params[0],
)
}
}
// --- ADMIN ---
func TestAdmin(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("grace")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "ADMIN",
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 256 RPL_ADMINME.
if !findNumeric(msgs, "256") {
t.Fatalf(
"expected RPL_ADMINME (256), got %v",
msgs,
)
}
// Expect 257 RPL_ADMINLOC1.
if !findNumeric(msgs, "257") {
t.Fatalf(
"expected RPL_ADMINLOC1 (257), got %v",
msgs,
)
}
// Expect 258 RPL_ADMINLOC2.
if !findNumeric(msgs, "258") {
t.Fatalf(
"expected RPL_ADMINLOC2 (258), got %v",
msgs,
)
}
// Expect 259 RPL_ADMINEMAIL.
if !findNumeric(msgs, "259") {
t.Fatalf(
"expected RPL_ADMINEMAIL (259), got %v",
msgs,
)
}
}
// --- INFO ---
func TestInfo(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("hank")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "INFO",
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 371 RPL_INFO (at least one).
if !findNumeric(msgs, "371") {
t.Fatalf(
"expected RPL_INFO (371), got %v",
msgs,
)
}
// Expect 374 RPL_ENDOFINFO.
if !findNumeric(msgs, "374") {
t.Fatalf(
"expected RPL_ENDOFINFO (374), got %v",
msgs,
)
}
}
// --- TIME ---
func TestTime(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("iris")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "TIME",
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 391 RPL_TIME.
msg := findNumericWithParams(msgs, "391")
if msg == nil {
t.Fatalf(
"expected RPL_TIME (391), got %v",
msgs,
)
}
}
// --- KILL ---
func TestKillSuccess(t *testing.T) {
tserver := newTestServerWithOper(t)
// Create the victim first.
victimToken := tserver.createSession("victim")
_ = victimToken
// Create oper user.
operToken := tserver.createSession("killer")
_, lastID := tserver.pollMessages(operToken, 0)
// Authenticate as oper.
tserver.sendCommand(operToken, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
_, lastID = tserver.pollMessages(operToken, lastID)
// Kill the victim.
status, result := tserver.sendCommand(
operToken, map[string]any{
commandKey: "KILL",
bodyKey: []string{"victim", "go away"},
},
)
if status != 200 {
t.Fatalf("expected 200, got %d: %v", status, result)
}
resultStatus, _ := result[statusKey].(string)
if resultStatus != "ok" {
t.Fatalf(
"expected status ok, got %v",
result,
)
}
// Verify the victim's session is gone by trying
// to WHOIS them.
tserver.sendCommand(operToken, map[string]any{
commandKey: "WHOIS",
toKey: "victim",
})
msgs, _ := tserver.pollMessages(operToken, lastID)
// Should get 401 ERR_NOSUCHNICK.
if !findNumeric(msgs, "401") {
t.Fatalf(
"expected victim to be gone (401), got %v",
msgs,
)
}
}
func TestKillNotOper(t *testing.T) {
tserver := newTestServer(t)
_ = tserver.createSession("target")
token := tserver.createSession("notoper")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "KILL",
bodyKey: []string{"target", "no reason"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 481 ERR_NOPRIVILEGES.
if !findNumeric(msgs, "481") {
t.Fatalf(
"expected ERR_NOPRIVILEGES (481), got %v",
msgs,
)
}
}
func TestKillNoParams(t *testing.T) {
tserver := newTestServerWithOper(t)
token := tserver.createSession("opertest")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
_, lastID = tserver.pollMessages(token, lastID)
tserver.sendCommand(token, map[string]any{
commandKey: "KILL",
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 461 ERR_NEEDMOREPARAMS.
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
// sendOperKillCommand is a helper that creates an oper
// session, authenticates, then sends KILL with the given
// target nick, and returns the resulting messages.
func sendOperKillCommand(
t *testing.T,
tserver *testServer,
operNick, targetNick string,
) []map[string]any {
t.Helper()
token := tserver.createSession(operNick)
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
_, lastID = tserver.pollMessages(token, lastID)
tserver.sendCommand(token, map[string]any{
commandKey: "KILL",
bodyKey: []string{targetNick},
})
msgs, _ := tserver.pollMessages(token, lastID)
return msgs
}
func TestKillNonexistentUser(t *testing.T) {
tserver := newTestServerWithOper(t)
msgs := sendOperKillCommand(
t, tserver, "opertest2", "ghost",
)
// Expect 401 ERR_NOSUCHNICK.
if !findNumeric(msgs, "401") {
t.Fatalf(
"expected ERR_NOSUCHNICK (401), got %v",
msgs,
)
}
}
func TestKillSelf(t *testing.T) {
tserver := newTestServerWithOper(t)
msgs := sendOperKillCommand(
t, tserver, "selfkiller", "selfkiller",
)
// Expect 483 ERR_CANTKILLSERVER.
if !findNumeric(msgs, "483") {
t.Fatalf(
"expected ERR_CANTKILLSERVER (483), got %v",
msgs,
)
}
}
func TestKillBroadcastsQuit(t *testing.T) {
tserver := newTestServerWithOper(t)
// Create victim and join a channel.
victimToken := tserver.createSession("vuser")
tserver.sendCommand(victimToken, map[string]any{
commandKey: joinCmd,
toKey: "#killtest",
})
// Create observer and join same channel.
observerToken := tserver.createSession("observer")
tserver.sendCommand(observerToken, map[string]any{
commandKey: joinCmd,
toKey: "#killtest",
})
_, lastObs := tserver.pollMessages(observerToken, 0)
// Create oper.
operToken := tserver.createSession("theoper2")
tserver.pollMessages(operToken, 0)
tserver.sendCommand(operToken, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
tserver.pollMessages(operToken, 0)
// Kill the victim.
tserver.sendCommand(operToken, map[string]any{
commandKey: "KILL",
bodyKey: []string{"vuser", "testing kill"},
})
// Observer should see a QUIT message.
msgs, _ := tserver.pollMessages(observerToken, lastObs)
foundQuit := false
for _, msg := range msgs {
cmd, _ := msg["command"].(string)
if cmd == "QUIT" {
from, _ := msg["from"].(string)
if from == "vuser" {
foundQuit = true
break
}
}
}
if !foundQuit {
t.Fatalf(
"expected QUIT from vuser, got %v",
msgs,
)
}
}
// --- WALLOPS ---
func TestWallopsSuccess(t *testing.T) {
tserver := newTestServerWithOper(t)
// Create receiver with +w.
receiverToken := tserver.createSession("receiver")
tserver.sendCommand(receiverToken, map[string]any{
commandKey: "MODE",
toKey: "receiver",
bodyKey: []string{"+w"},
})
_, lastRecv := tserver.pollMessages(receiverToken, 0)
// Create oper.
operToken := tserver.createSession("walloper")
tserver.pollMessages(operToken, 0)
tserver.sendCommand(operToken, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
tserver.pollMessages(operToken, 0)
// Also set +w on oper so they receive it too.
tserver.sendCommand(operToken, map[string]any{
commandKey: "MODE",
toKey: "walloper",
bodyKey: []string{"+w"},
})
tserver.pollMessages(operToken, 0)
// Send WALLOPS.
tserver.sendCommand(operToken, map[string]any{
commandKey: "WALLOPS",
bodyKey: []string{"server going down"},
})
// Receiver should get the WALLOPS message.
msgs, _ := tserver.pollMessages(receiverToken, lastRecv)
foundWallops := false
for _, msg := range msgs {
cmd, _ := msg["command"].(string)
if cmd == "WALLOPS" {
foundWallops = true
break
}
}
if !foundWallops {
t.Fatalf(
"expected WALLOPS message, got %v",
msgs,
)
}
}
func TestWallopsNotOper(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("notoper2")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "WALLOPS",
bodyKey: []string{"hello"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 481 ERR_NOPRIVILEGES.
if !findNumeric(msgs, "481") {
t.Fatalf(
"expected ERR_NOPRIVILEGES (481), got %v",
msgs,
)
}
}
func TestWallopsNoParams(t *testing.T) {
tserver := newTestServerWithOper(t)
token := tserver.createSession("operempty")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
_, lastID = tserver.pollMessages(token, lastID)
tserver.sendCommand(token, map[string]any{
commandKey: "WALLOPS",
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 461 ERR_NEEDMOREPARAMS.
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestWallopsNotReceivedWithoutW(t *testing.T) {
tserver := newTestServerWithOper(t)
// Create receiver WITHOUT +w.
receiverToken := tserver.createSession("nowallops")
_, lastRecv := tserver.pollMessages(receiverToken, 0)
// Create oper.
operToken := tserver.createSession("walloper2")
tserver.pollMessages(operToken, 0)
tserver.sendCommand(operToken, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
tserver.pollMessages(operToken, 0)
// Send WALLOPS.
tserver.sendCommand(operToken, map[string]any{
commandKey: "WALLOPS",
bodyKey: []string{"secret message"},
})
// Receiver should NOT get the WALLOPS message.
msgs, _ := tserver.pollMessages(receiverToken, lastRecv)
for _, msg := range msgs {
cmd, _ := msg["command"].(string)
if cmd == "WALLOPS" {
t.Fatalf(
"did not expect WALLOPS for user "+
"without +w, got %v",
msgs,
)
}
}
}
// --- User Mode +w ---
func TestUserModeSetW(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("wmoder")
_, lastID := tserver.pollMessages(token, 0)
// Set +w.
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "wmoder",
bodyKey: []string{"+w"},
})
msgs, lastID := tserver.pollMessages(token, lastID)
// Expect 221 RPL_UMODEIS with "+w".
msg := findNumericWithParams(msgs, "221")
if msg == nil {
t.Fatalf(
"expected RPL_UMODEIS (221), got %v",
msgs,
)
}
body := getNumericBody(msg)
if !strings.Contains(body, "w") {
t.Fatalf(
"expected mode string to contain 'w', got %q",
body,
)
}
// Now query mode.
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "wmoder",
})
msgs, _ = tserver.pollMessages(token, lastID)
msg = findNumericWithParams(msgs, "221")
if msg == nil {
t.Fatalf(
"expected RPL_UMODEIS (221) on query, got %v",
msgs,
)
}
body = getNumericBody(msg)
if !strings.Contains(body, "w") {
t.Fatalf(
"expected mode '+w' in query, got %q",
body,
)
}
}
func TestUserModeUnsetW(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("wunsetter")
_, lastID := tserver.pollMessages(token, 0)
// Set +w first.
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "wunsetter",
bodyKey: []string{"+w"},
})
_, lastID = tserver.pollMessages(token, lastID)
// Unset -w.
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "wunsetter",
bodyKey: []string{"-w"},
})
msgs, _ := tserver.pollMessages(token, lastID)
msg := findNumericWithParams(msgs, "221")
if msg == nil {
t.Fatalf(
"expected RPL_UMODEIS (221), got %v",
msgs,
)
}
body := getNumericBody(msg)
if strings.Contains(body, "w") {
t.Fatalf(
"expected 'w' to be removed, got %q",
body,
)
}
}
func TestUserModeUnknownFlag(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("badmode")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "badmode",
bodyKey: []string{"+z"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 501 ERR_UMODEUNKNOWNFLAG.
if !findNumeric(msgs, "501") {
t.Fatalf(
"expected ERR_UMODEUNKNOWNFLAG (501), got %v",
msgs,
)
}
}
func TestUserModeCannotSetO(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("tryoper")
_, lastID := tserver.pollMessages(token, 0)
// Try to set +o via MODE (should fail).
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "tryoper",
bodyKey: []string{"+o"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 501 ERR_UMODEUNKNOWNFLAG.
if !findNumeric(msgs, "501") {
t.Fatalf(
"expected ERR_UMODEUNKNOWNFLAG (501), got %v",
msgs,
)
}
}
func TestUserModeDeoper(t *testing.T) {
tserver := newTestServerWithOper(t)
token := tserver.createSession("deoper")
_, lastID := tserver.pollMessages(token, 0)
// Authenticate as oper.
tserver.sendCommand(token, map[string]any{
commandKey: "OPER",
bodyKey: []string{testOperName, testOperPassword},
})
_, lastID = tserver.pollMessages(token, lastID)
// Use MODE -o to de-oper.
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "deoper",
bodyKey: []string{"-o"},
})
msgs, _ := tserver.pollMessages(token, lastID)
msg := findNumericWithParams(msgs, "221")
if msg == nil {
t.Fatalf(
"expected RPL_UMODEIS (221), got %v",
msgs,
)
}
body := getNumericBody(msg)
if strings.Contains(body, "o") {
t.Fatalf(
"expected 'o' to be removed, got %q",
body,
)
}
}
func TestUserModeCannotChangeOtherUser(t *testing.T) {
tserver := newTestServer(t)
_ = tserver.createSession("other")
token := tserver.createSession("changer")
_, lastID := tserver.pollMessages(token, 0)
// Try to change another user's mode.
tserver.sendCommand(token, map[string]any{
commandKey: "MODE",
toKey: "other",
bodyKey: []string{"+w"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 502 ERR_USERSDONTMATCH.
if !findNumeric(msgs, "502") {
t.Fatalf(
"expected ERR_USERSDONTMATCH (502), got %v",
msgs,
)
}
}
// getNumericBody extracts the body text from a numeric
// message. The body is stored as a JSON array; this
// returns the first element.
func getNumericBody(msg map[string]any) string {
raw, exists := msg["body"]
if !exists || raw == nil {
return ""
}
arr, isArr := raw.([]any)
if !isArr || len(arr) == 0 {
return ""
}
str, isStr := arr[0].(string)
if !isStr {
return ""
}
return str
}

View File

@@ -5,7 +5,7 @@ import (
"encoding/hex" "encoding/hex"
"testing" "testing"
"git.eeqj.de/sneak/neoirc/internal/hashcash" "sneak.berlin/go/neoirc/internal/hashcash"
) )
const ( const (

View File

@@ -9,7 +9,7 @@ import (
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/neoirc/internal/hashcash" "sneak.berlin/go/neoirc/internal/hashcash"
) )
const testBits = 2 const testBits = 2

View File

@@ -6,12 +6,12 @@ import (
"log/slog" "log/slog"
"time" "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" "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. // Params defines the dependencies for creating a Healthcheck.

View File

@@ -8,10 +8,29 @@ import (
"strings" "strings"
"time" "time"
"git.eeqj.de/sneak/neoirc/internal/service" "sneak.berlin/go/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/pkg/irc" "sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/pkg/irc"
) )
// versionString returns the server version for IRC
// responses, falling back to "neoirc-dev" when globals
// are not set (e.g. during tests).
func versionString() string {
name := globals.Appname
ver := globals.Version
if name == "" {
name = "neoirc"
}
if ver == "" {
ver = "dev"
}
return name + "-" + ver
}
// sendIRCError maps a service.IRCError to an IRC numeric // sendIRCError maps a service.IRCError to an IRC numeric
// reply on the wire. // reply on the wire.
func (c *Conn) sendIRCError(err error) { func (c *Conn) sendIRCError(err error) {
@@ -431,7 +450,7 @@ func (c *Conn) handleMode(
if strings.HasPrefix(target, "#") { if strings.HasPrefix(target, "#") {
c.handleChannelMode(ctx, msg) c.handleChannelMode(ctx, msg)
} else { } else {
c.handleUserMode(msg) c.handleUserMode(ctx, msg)
} }
} }
@@ -694,7 +713,10 @@ func (c *Conn) applyChannelModes(
} }
// handleUserMode handles MODE for users. // handleUserMode handles MODE for users.
func (c *Conn) handleUserMode(msg *Message) { func (c *Conn) handleUserMode(
ctx context.Context,
msg *Message,
) {
target := msg.Params[0] target := msg.Params[0]
if !strings.EqualFold(target, c.nick) { if !strings.EqualFold(target, c.nick) {
@@ -706,8 +728,34 @@ func (c *Conn) handleUserMode(msg *Message) {
return return
} }
// We don't support user modes beyond the basics. // Mode query (no mode string).
c.sendNumeric(irc.RplUmodeIs, "+") if len(msg.Params) < 2 { //nolint:mnd
modes := c.svc.QueryUserMode(ctx, c.sessionID)
c.sendNumeric(irc.RplUmodeIs, modes)
return
}
newModes, err := c.svc.ApplyUserMode(
ctx, c.sessionID, msg.Params[1],
)
if err != nil {
var ircErr *service.IRCError
if errors.As(err, &ircErr) {
c.sendNumeric(ircErr.Code, ircErr.Message)
return
}
c.sendNumeric(
irc.ErrUmodeUnknownFlag,
"Unknown MODE flag",
)
return
}
c.sendNumeric(irc.RplUmodeIs, newModes)
} }
// handleNames replies with channel member list. // handleNames replies with channel member list.
@@ -1299,3 +1347,191 @@ func (c *Conn) handleUserhost(
strings.Join(replies, " "), strings.Join(replies, " "),
) )
} }
// handleVersion replies with the server version string.
func (c *Conn) handleVersion(ctx context.Context) {
_ = ctx
version := versionString()
c.sendNumeric(
irc.RplVersion,
version+".", c.cfg.ServerName,
"",
)
}
// handleAdmin replies with server admin info.
func (c *Conn) handleAdmin(ctx context.Context) {
_ = ctx
srvName := c.cfg.ServerName
c.sendNumeric(
irc.RplAdminMe,
srvName, "Administrative info",
)
c.sendNumeric(
irc.RplAdminLoc1,
"neoirc server",
)
c.sendNumeric(
irc.RplAdminLoc2,
"IRC over HTTP",
)
c.sendNumeric(
irc.RplAdminEmail,
"admin@"+srvName,
)
}
// handleInfo replies with server software info.
func (c *Conn) handleInfo(ctx context.Context) {
_ = ctx
infoLines := []string{
"neoirc — IRC semantics over HTTP",
"Version: " + versionString(),
"Written in Go",
}
for _, line := range infoLines {
c.sendNumeric(irc.RplInfo, line)
}
c.sendNumeric(
irc.RplEndOfInfo,
"End of /INFO list",
)
}
// handleTime replies with the server's current time.
func (c *Conn) handleTime(ctx context.Context) {
_ = ctx
srvName := c.cfg.ServerName
c.sendNumeric(
irc.RplTime,
srvName, time.Now().Format(time.RFC1123),
)
}
// handleKillCmd forcibly disconnects a target user (oper
// only).
func (c *Conn) handleKillCmd(
ctx context.Context,
msg *Message,
) {
isOper, err := c.database.IsSessionOper(
ctx, c.sessionID,
)
if err != nil || !isOper {
c.sendNumeric(
irc.ErrNoPrivileges,
"Permission Denied- "+
"You're not an IRC operator",
)
return
}
if len(msg.Params) < 1 {
c.sendNumeric(
irc.ErrNeedMoreParams,
"KILL", "Not enough parameters",
)
return
}
targetNick := msg.Params[0]
reason := "KILLed"
if len(msg.Params) > 1 {
reason = msg.Params[1]
}
if targetNick == c.nick {
c.sendNumeric(
irc.ErrCantKillServer,
"You cannot KILL yourself",
)
return
}
targetSID, lookupErr := c.database.GetSessionByNick(
ctx, targetNick,
)
if lookupErr != nil {
c.sendNumeric(
irc.ErrNoSuchNick,
targetNick, "No such nick/channel",
)
return
}
quitReason := "Killed (" + c.nick + " (" + reason + "))"
c.svc.BroadcastQuit(
ctx, targetSID, targetNick, quitReason,
)
}
// handleWallopsCmd broadcasts to all +w users (oper only).
func (c *Conn) handleWallopsCmd(
ctx context.Context,
msg *Message,
) {
isOper, err := c.database.IsSessionOper(
ctx, c.sessionID,
)
if err != nil || !isOper {
c.sendNumeric(
irc.ErrNoPrivileges,
"Permission Denied- "+
"You're not an IRC operator",
)
return
}
if len(msg.Params) < 1 {
c.sendNumeric(
irc.ErrNeedMoreParams,
"WALLOPS", "Not enough parameters",
)
return
}
message := msg.Params[0]
wallopsSIDs, wallErr := c.database.
GetWallopsSessionIDs(ctx)
if wallErr != nil {
c.log.Error(
"get wallops sessions failed",
"error", wallErr,
)
return
}
if len(wallopsSIDs) > 0 {
body, mErr := json.Marshal([]string{message})
if mErr != nil {
return
}
_, _, _ = c.svc.FanOut(
ctx, irc.CmdWallops, c.nick, "*",
nil, body, nil, wallopsSIDs,
)
}
}

View File

@@ -11,11 +11,11 @@ import (
"sync" "sync"
"time" "time"
"git.eeqj.de/sneak/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/broker"
"git.eeqj.de/sneak/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/service" "sneak.berlin/go/neoirc/internal/service"
"git.eeqj.de/sneak/neoirc/pkg/irc" "sneak.berlin/go/neoirc/pkg/irc"
) )
const ( const (
@@ -131,6 +131,12 @@ func (c *Conn) buildCommandMap() map[string]cmdHandler {
c.handleCAP(msg) c.handleCAP(msg)
}, },
"USERHOST": c.handleUserhost, "USERHOST": c.handleUserhost,
irc.CmdVersion: func(ctx context.Context, _ *Message) { c.handleVersion(ctx) },
irc.CmdAdmin: func(ctx context.Context, _ *Message) { c.handleAdmin(ctx) },
irc.CmdInfo: func(ctx context.Context, _ *Message) { c.handleInfo(ctx) },
irc.CmdTime: func(ctx context.Context, _ *Message) { c.handleTime(ctx) },
irc.CmdKill: c.handleKillCmd,
irc.CmdWallops: c.handleWallopsCmd,
} }
} }

View File

@@ -5,10 +5,10 @@ import (
"log/slog" "log/slog"
"net" "net"
"git.eeqj.de/sneak/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/broker"
"git.eeqj.de/sneak/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/service" "sneak.berlin/go/neoirc/internal/service"
) )
// NewTestServer creates a Server suitable for testing. // NewTestServer creates a Server suitable for testing.

View File

@@ -760,6 +760,288 @@ func TestIntegrationTwoClients(t *testing.T) {
) )
} }
// ── Tier 3 Utility Command Integration Tests ──────────
// TestIntegrationUserhost verifies the USERHOST command
// returns user@host info for connected nicks.
func TestIntegrationUserhost(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
bob := env.dial(t)
bob.register("bob")
// Query single nick.
alice.send("USERHOST bob")
aliceReply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 302 ")
})
assertContains(
t, aliceReply, " 302 ",
"RPL_USERHOST",
)
assertContains(
t, aliceReply, "bob",
"USERHOST contains queried nick",
)
// Query multiple nicks.
bob.send("USERHOST alice bob")
bobReply := bob.readUntil(func(l string) bool {
return strings.Contains(l, " 302 ")
})
assertContains(
t, bobReply, " 302 ",
"RPL_USERHOST multi-nick",
)
assertContains(
t, bobReply, "alice",
"USERHOST multi contains alice",
)
assertContains(
t, bobReply, "bob",
"USERHOST multi contains bob",
)
}
// TestIntegrationVersion verifies the VERSION command
// returns the server version string.
func TestIntegrationVersion(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.send("VERSION")
aliceReply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 351 ")
})
assertContains(
t, aliceReply, " 351 ",
"RPL_VERSION",
)
assertContains(
t, aliceReply, "neoirc",
"VERSION reply contains server name",
)
}
// TestIntegrationAdmin verifies the ADMIN command returns
// server admin info (256259 numerics).
func TestIntegrationAdmin(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.send("ADMIN")
aliceReply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 259 ")
})
assertContains(
t, aliceReply, " 256 ",
"RPL_ADMINME",
)
assertContains(
t, aliceReply, " 257 ",
"RPL_ADMINLOC1",
)
assertContains(
t, aliceReply, " 258 ",
"RPL_ADMINLOC2",
)
assertContains(
t, aliceReply, " 259 ",
"RPL_ADMINEMAIL",
)
}
// TestIntegrationInfo verifies the INFO command returns
// server information (371/374 numerics).
func TestIntegrationInfo(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.send("INFO")
aliceReply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 374 ")
})
assertContains(
t, aliceReply, " 371 ",
"RPL_INFO",
)
assertContains(
t, aliceReply, " 374 ",
"RPL_ENDOFINFO",
)
assertContains(
t, aliceReply, "neoirc",
"INFO reply mentions server name",
)
}
// TestIntegrationTime verifies the TIME command returns
// the server time (391 numeric).
func TestIntegrationTime(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.send("TIME")
aliceReply := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 391 ")
})
assertContains(
t, aliceReply, " 391 ",
"RPL_TIME",
)
assertContains(
t, aliceReply, "test.irc",
"TIME reply includes server name",
)
}
// TestIntegrationKill verifies the KILL command: oper can
// kill a user, non-oper cannot.
func TestIntegrationKill(t *testing.T) {
t.Parallel()
env := newTestEnvWithOper(t)
alice := env.dial(t)
alice.register("alice")
bob := env.dial(t)
bob.register("bob")
// Both join a channel so KILL's QUIT is visible.
alice.joinAndDrain("#killtest")
bob.joinAndDrain("#killtest")
// Drain alice's view of bob's join.
alice.readUntil(func(l string) bool {
return strings.Contains(l, "JOIN") &&
strings.Contains(l, "bob")
})
// Non-oper KILL should fail.
alice.send("KILL bob :nope")
aliceKillFail := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 481 ")
})
assertContains(
t, aliceKillFail, " 481 ",
"ERR_NOPRIVILEGES for non-oper KILL",
)
// alice becomes oper.
alice.send("OPER testoper testpass")
alice.readUntil(func(l string) bool {
return strings.Contains(l, " 381 ")
})
// Oper KILL should succeed.
alice.send("KILL bob :bad behavior")
// alice should see bob's QUIT relay.
aliceSeesQuit := alice.readUntil(func(l string) bool {
return strings.Contains(l, "QUIT") &&
strings.Contains(l, "bob")
})
assertContains(
t, aliceSeesQuit, "Killed",
"KILL reason in QUIT message",
)
// KILL nonexistent nick.
alice.send("KILL nobody123 :gone")
aliceNoSuch := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 401 ")
})
assertContains(
t, aliceNoSuch, " 401 ",
"ERR_NOSUCHNICK for KILL missing target",
)
}
// TestIntegrationWallops verifies the WALLOPS command:
// oper can broadcast to +w users.
func TestIntegrationWallops(t *testing.T) {
t.Parallel()
env := newTestEnvWithOper(t)
alice := env.dial(t)
alice.register("alice")
bob := env.dial(t)
bob.register("bob")
// Non-oper WALLOPS should fail.
alice.send("WALLOPS :test broadcast")
aliceWallopsFail := alice.readUntil(func(l string) bool {
return strings.Contains(l, " 481 ")
})
assertContains(
t, aliceWallopsFail, " 481 ",
"ERR_NOPRIVILEGES for non-oper WALLOPS",
)
// alice becomes oper.
alice.send("OPER testoper testpass")
alice.readUntil(func(l string) bool {
return strings.Contains(l, " 381 ")
})
// bob sets +w to receive wallops.
bob.send("MODE bob +w")
bob.readUntil(func(l string) bool {
return strings.Contains(l, " 221 ")
})
// alice sends WALLOPS.
alice.send("WALLOPS :important announcement")
// bob (who has +w) should receive it.
bobWallops := bob.readUntil(func(l string) bool {
return strings.Contains(
l, "important announcement",
)
})
assertContains(
t, bobWallops, "important announcement",
"bob receives WALLOPS message",
)
assertContains(
t, bobWallops, "WALLOPS",
"message is WALLOPS command",
)
}
// TestIntegrationModeSecret tests +s (secret) channel // TestIntegrationModeSecret tests +s (secret) channel
// mode — verifies that +s can be set and the mode is // mode — verifies that +s can be set and the mode is
// reflected in MODE queries. // reflected in MODE queries.

View File

@@ -3,7 +3,7 @@ package ircserver_test
import ( import (
"testing" "testing"
"git.eeqj.de/sneak/neoirc/internal/ircserver" "sneak.berlin/go/neoirc/internal/ircserver"
) )
//nolint:funlen // table-driven test //nolint:funlen // table-driven test

View File

@@ -6,8 +6,8 @@ import (
"strings" "strings"
"time" "time"
"git.eeqj.de/sneak/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/pkg/irc" "sneak.berlin/go/neoirc/pkg/irc"
) )
// relayMessages polls the client output queue and delivers // relayMessages polls the client output queue and delivers
@@ -120,6 +120,8 @@ func (c *Conn) deliverIRCMessage(
c.deliverKickMsg(msg, text) c.deliverKickMsg(msg, text)
case command == "INVITE": case command == "INVITE":
c.deliverInviteMsg(msg, text) c.deliverInviteMsg(msg, text)
case command == irc.CmdWallops:
c.deliverWallops(msg, text)
case command == irc.CmdMode: case command == irc.CmdMode:
c.deliverMode(msg, text) c.deliverMode(msg, text)
case command == irc.CmdPing: case command == irc.CmdPing:
@@ -305,6 +307,18 @@ func (c *Conn) deliverInviteMsg(
c.sendFromServer("NOTICE", c.nick, text) c.sendFromServer("NOTICE", c.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. // deliverMode sends a MODE change notification.
func (c *Conn) deliverMode( func (c *Conn) deliverMode(
msg *db.IRCMessage, msg *db.IRCMessage,

View File

@@ -7,12 +7,12 @@ import (
"net" "net"
"sync" "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" "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 // Params defines the dependencies for creating an IRC

View File

@@ -11,10 +11,10 @@ import (
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/broker"
"git.eeqj.de/sneak/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/ircserver" "sneak.berlin/go/neoirc/internal/ircserver"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -112,6 +112,87 @@ func newTestEnv(t *testing.T) *testEnv {
} }
} }
// newTestEnvWithOper creates a test environment with oper
// credentials configured.
func newTestEnvWithOper(t *testing.T) *testEnv {
t.Helper()
dsn := fmt.Sprintf(
"file:%s?mode=memory&cache=shared&_journal_mode=WAL",
t.Name(),
)
conn, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("open db: %v", err)
}
conn.SetMaxOpenConns(1)
_, err = conn.ExecContext(
t.Context(), "PRAGMA foreign_keys = ON",
)
if err != nil {
t.Fatalf("pragma: %v", err)
}
database := db.NewTestDatabaseFromConn(conn)
err = database.RunMigrations(t.Context())
if err != nil {
t.Fatalf("migrate: %v", err)
}
brk := broker.New()
cfg := &config.Config{ //nolint:exhaustruct
ServerName: "test.irc",
MOTD: "Welcome to test IRC",
OperName: "testoper",
OperPassword: "testpass",
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
addr := listener.Addr().String()
err = listener.Close()
if err != nil {
t.Fatalf("close listener: %v", err)
}
log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
))
srv := ircserver.NewTestServer(log, cfg, database, brk)
err = srv.Start(addr)
if err != nil {
t.Fatalf("start irc server: %v", err)
}
t.Cleanup(func() {
srv.Stop()
err := conn.Close()
if err != nil {
t.Logf("close db: %v", err)
}
})
return &testEnv{
database: database,
brk: brk,
cfg: cfg,
srv: srv,
}
}
// dial connects to the test server. // dial connects to the test server.
func (env *testEnv) dial(t *testing.T) *testClient { func (env *testEnv) dial(t *testing.T) *testClient {
t.Helper() t.Helper()

View File

@@ -5,8 +5,8 @@ import (
"log/slog" "log/slog"
"os" "os"
"git.eeqj.de/sneak/neoirc/internal/globals"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/globals"
) )
// Params defines the dependencies for creating a Logger. // Params defines the dependencies for creating a Logger.

View File

@@ -7,9 +7,6 @@ import (
"net/http" "net/http"
"time" "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" basicauth "github.com/99designs/basicauth-go"
chimw "github.com/go-chi/chi/v5/middleware" chimw "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors" "github.com/go-chi/cors"
@@ -18,6 +15,9 @@ import (
"github.com/slok/go-http-metrics/middleware/std" "github.com/slok/go-http-metrics/middleware/std"
"github.com/spf13/viper" "github.com/spf13/viper"
"go.uber.org/fx" "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 const corsMaxAge = 300

View File

@@ -3,7 +3,7 @@ package ratelimit_test
import ( import (
"testing" "testing"
"git.eeqj.de/sneak/neoirc/internal/ratelimit" "sneak.berlin/go/neoirc/internal/ratelimit"
) )
func TestNewCreatesLimiter(t *testing.T) { func TestNewCreatesLimiter(t *testing.T) {

View File

@@ -5,7 +5,7 @@ import (
"net/http" "net/http"
"time" "time"
"git.eeqj.de/sneak/neoirc/web" "sneak.berlin/go/neoirc/web"
sentryhttp "github.com/getsentry/sentry-go/http" sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"

View File

@@ -12,12 +12,12 @@ import (
"syscall" "syscall"
"time" "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" "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/getsentry/sentry-go"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"

View File

@@ -11,12 +11,12 @@ import (
"strconv" "strconv"
"strings" "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" "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. // Params defines the dependencies for creating a Service.
@@ -791,6 +791,109 @@ func (s *Service) QueryChannelMode(
return modes + modeParams return modes + modeParams
} }
// QueryUserMode returns the current user mode string for
// the given session (e.g. "+ow", "+w", "+").
func (s *Service) QueryUserMode(
ctx context.Context,
sessionID int64,
) string {
modes := "+"
isOper, err := s.db.IsSessionOper(ctx, sessionID)
if err == nil && isOper {
modes += "o"
}
isWallops, err := s.db.IsSessionWallops(
ctx, sessionID,
)
if err == nil && isWallops {
modes += "w"
}
return modes
}
// ApplyUserMode parses a mode string character by
// character (e.g. "+wo", "-w") and applies each mode
// change to the session. Returns the resulting mode string
// after all changes, or an IRCError on failure.
func (s *Service) ApplyUserMode(
ctx context.Context,
sessionID int64,
modeStr string,
) (string, error) {
if len(modeStr) < 2 { //nolint:mnd // +/- prefix + ≥1 char
return "", &IRCError{
Code: irc.ErrUmodeUnknownFlag,
Params: nil,
Message: "Unknown MODE flag",
}
}
adding := modeStr[0] == '+'
for _, ch := range modeStr[1:] {
if err := s.applySingleUserMode(
ctx, sessionID, ch, adding,
); err != nil {
return "", err
}
}
return s.QueryUserMode(ctx, sessionID), nil
}
// applySingleUserMode applies one user mode character.
func (s *Service) applySingleUserMode(
ctx context.Context,
sessionID int64,
modeChar rune,
adding bool,
) error {
switch modeChar {
case 'w':
err := s.db.SetSessionWallops(
ctx, sessionID, adding,
)
if err != nil {
s.log.Error(
"set wallops mode failed", "error", err,
)
return fmt.Errorf("set wallops: %w", err)
}
case 'o':
// +o cannot be set via MODE; use OPER command.
if adding {
return &IRCError{
Code: irc.ErrUmodeUnknownFlag,
Params: nil,
Message: "Unknown MODE flag",
}
}
err := s.db.SetSessionOper(
ctx, sessionID, false,
)
if err != nil {
s.log.Error(
"clear oper mode failed", "error", err,
)
return fmt.Errorf("clear oper: %w", err)
}
default:
return &IRCError{
Code: irc.ErrUmodeUnknownFlag,
Params: nil,
Message: "Unknown MODE flag",
}
}
return nil
}
// broadcastNickChange notifies channel peers of a nick // broadcastNickChange notifies channel peers of a nick
// change. // change.
func (s *Service) broadcastNickChange( func (s *Service) broadcastNickChange(

View File

@@ -12,16 +12,16 @@ import (
"os" "os"
"testing" "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"
"go.uber.org/fx/fxtest" "go.uber.org/fx/fxtest"
"golang.org/x/crypto/bcrypt" "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) { func TestMain(m *testing.M) {
@@ -363,3 +363,166 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
t.Errorf("operator should be able to send in moderated channel: %v", err) t.Errorf("operator should be able to send in moderated channel: %v", err)
} }
} }
func TestQueryUserMode(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()
sid := createSession(ctx, t, env.db, "alice")
// Fresh session has no modes.
modes := env.svc.QueryUserMode(ctx, sid)
if modes != "+" {
t.Errorf("expected +, got %s", modes)
}
// Set wallops.
_ = env.db.SetSessionWallops(ctx, sid, true)
modes = env.svc.QueryUserMode(ctx, sid)
if modes != "+w" {
t.Errorf("expected +w, got %s", modes)
}
// Set oper.
_ = env.db.SetSessionOper(ctx, sid, true)
modes = env.svc.QueryUserMode(ctx, sid)
if modes != "+ow" {
t.Errorf("expected +ow, got %s", modes)
}
}
func TestApplyUserModeSingleChar(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()
sid := createSession(ctx, t, env.db, "alice")
// Apply +w.
result, err := env.svc.ApplyUserMode(ctx, sid, "+w")
if err != nil {
t.Fatalf("apply +w: %v", err)
}
if result != "+w" {
t.Errorf("expected +w, got %s", result)
}
// Apply -w.
result, err = env.svc.ApplyUserMode(ctx, sid, "-w")
if err != nil {
t.Fatalf("apply -w: %v", err)
}
if result != "+" {
t.Errorf("expected +, got %s", result)
}
}
func TestApplyUserModeMultiChar(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()
sid := createSession(ctx, t, env.db, "alice")
// Set oper first so we can test +wo (w applied, o
// rejected because +o is not allowed via MODE).
_ = env.db.SetSessionOper(ctx, sid, true)
// Apply +w alone should work.
result, err := env.svc.ApplyUserMode(ctx, sid, "+w")
if err != nil {
t.Fatalf("apply +w: %v", err)
}
if result != "+ow" {
t.Errorf("expected +ow, got %s", result)
}
// Reset wallops.
_ = env.db.SetSessionWallops(ctx, sid, false)
// Multi-char -ow: should de-oper and remove wallops.
_ = env.db.SetSessionWallops(ctx, sid, true)
result, err = env.svc.ApplyUserMode(ctx, sid, "-ow")
if err != nil {
t.Fatalf("apply -ow: %v", err)
}
if result != "+" {
t.Errorf("expected +, got %s", result)
}
// +wo should fail because +o is not allowed.
_, err = env.svc.ApplyUserMode(ctx, sid, "+wo")
var ircErr *service.IRCError
if !errors.As(err, &ircErr) {
t.Fatalf("expected IRCError, got %v", err)
}
if ircErr.Code != irc.ErrUmodeUnknownFlag {
t.Errorf(
"expected ErrUmodeUnknownFlag, got %d",
ircErr.Code,
)
}
}
func TestApplyUserModeInvalidInput(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()
sid := createSession(ctx, t, env.db, "alice")
// Too short.
_, err := env.svc.ApplyUserMode(ctx, sid, "+")
var ircErr *service.IRCError
if !errors.As(err, &ircErr) {
t.Fatalf("expected IRCError for short input, got %v", err)
}
// Unknown flag.
_, err = env.svc.ApplyUserMode(ctx, sid, "+x")
if !errors.As(err, &ircErr) {
t.Fatalf("expected IRCError for unknown flag, got %v", err)
}
if ircErr.Code != irc.ErrUmodeUnknownFlag {
t.Errorf(
"expected ErrUmodeUnknownFlag, got %d",
ircErr.Code,
)
}
}
func TestApplyUserModeDeoper(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()
sid := createSession(ctx, t, env.db, "alice")
// Make oper via DB directly.
_ = env.db.SetSessionOper(ctx, sid, true)
// -o should work.
result, err := env.svc.ApplyUserMode(ctx, sid, "-o")
if err != nil {
t.Fatalf("apply -o: %v", err)
}
if result != "+" {
t.Errorf("expected +, got %s", result)
}
// +o should fail.
_, err = env.svc.ApplyUserMode(ctx, sid, "+o")
var ircErr *service.IRCError
if !errors.As(err, &ircErr) {
t.Fatalf("expected IRCError for +o, got %v", err)
}
}

View File

@@ -3,7 +3,7 @@ package stats_test
import ( import (
"testing" "testing"
"git.eeqj.de/sneak/neoirc/internal/stats" "sneak.berlin/go/neoirc/internal/stats"
) )
func TestNew(t *testing.T) { func TestNew(t *testing.T) {

View File

@@ -2,10 +2,13 @@ package irc
// IRC command names (RFC 1459 / RFC 2812). // IRC command names (RFC 1459 / RFC 2812).
const ( const (
CmdAdmin = "ADMIN"
CmdAway = "AWAY" CmdAway = "AWAY"
CmdInfo = "INFO"
CmdInvite = "INVITE" CmdInvite = "INVITE"
CmdJoin = "JOIN" CmdJoin = "JOIN"
CmdKick = "KICK" CmdKick = "KICK"
CmdKill = "KILL"
CmdList = "LIST" CmdList = "LIST"
CmdLusers = "LUSERS" CmdLusers = "LUSERS"
CmdMode = "MODE" CmdMode = "MODE"
@@ -20,8 +23,12 @@ const (
CmdPong = "PONG" CmdPong = "PONG"
CmdPrivmsg = "PRIVMSG" CmdPrivmsg = "PRIVMSG"
CmdQuit = "QUIT" CmdQuit = "QUIT"
CmdTime = "TIME"
CmdTopic = "TOPIC" CmdTopic = "TOPIC"
CmdUser = "USER" CmdUser = "USER"
CmdUserhost = "USERHOST"
CmdVersion = "VERSION"
CmdWallops = "WALLOPS"
CmdWho = "WHO" CmdWho = "WHO"
CmdWhois = "WHOIS" CmdWhois = "WHOIS"
) )

View File

@@ -4,7 +4,7 @@ import (
"errors" "errors"
"testing" "testing"
"git.eeqj.de/sneak/neoirc/pkg/irc" "sneak.berlin/go/neoirc/pkg/irc"
) )
func TestName(t *testing.T) { func TestName(t *testing.T) {

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "title": "JOIN",
"description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.", "description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "title": "KICK",
"description": "Kick a user from a channel. RFC 1459 §4.2.8.", "description": "Kick a user from a channel. RFC 1459 §4.2.8.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "title": "MODE",
"description": "Set or query channel/user modes. RFC 1459 §4.2.3.", "description": "Set or query channel/user modes. RFC 1459 §4.2.3.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "title": "NICK",
"description": "Change nickname. C2S: request new nick. S2C: notification of nick change. RFC 1459 §4.1.2.", "description": "Change nickname. C2S: request new nick. S2C: notification of nick change. RFC 1459 §4.1.2.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "title": "NOTICE",
"description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.", "description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "title": "PART",
"description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.", "description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "title": "PING",
"description": "Keepalive. C2S or S2S. Server responds with PONG. RFC 1459 §4.6.2.", "description": "Keepalive. C2S or S2S. Server responds with PONG. RFC 1459 §4.6.2.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "title": "PONG",
"description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.", "description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "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.", "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", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "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.", "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", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "title": "QUIT",
"description": "User disconnected. S2C only. RFC 1459 §4.1.6.", "description": "User disconnected. S2C only. RFC 1459 §4.1.6.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "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.", "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", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "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.).", "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", "type": "object",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "title": "001 RPL_WELCOME",
"description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.", "description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "title": "002 RPL_YOURHOST",
"description": "Server host info sent after session creation. RFC 2812 \u00a75.1.", "description": "Server host info sent after session creation. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "title": "003 RPL_CREATED",
"description": "Server creation date. RFC 2812 \u00a75.1.", "description": "Server creation date. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "title": "004 RPL_MYINFO",
"description": "Server info (name, version, available modes). RFC 2812 \u00a75.1.", "description": "Server info (name, version, available modes). RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "title": "322 RPL_LIST",
"description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.", "description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "title": "323 RPL_LISTEND",
"description": "End of channel list. RFC 1459 \u00a76.2.", "description": "End of channel list. RFC 1459 \u00a76.2.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "title": "332 RPL_TOPIC",
"description": "Channel topic (sent on JOIN or TOPIC query). RFC 1459 \u00a76.2.", "description": "Channel topic (sent on JOIN or TOPIC query). RFC 1459 \u00a76.2.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "title": "353 RPL_NAMREPLY",
"description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.", "description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "title": "366 RPL_ENDOFNAMES",
"description": "End of NAMES list. RFC 1459 \u00a76.2.", "description": "End of NAMES list. RFC 1459 \u00a76.2.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "title": "372 RPL_MOTD",
"description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.", "description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "title": "375 RPL_MOTDSTART",
"description": "Start of MOTD. RFC 2812 \u00a75.1.", "description": "Start of MOTD. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "title": "376 RPL_ENDOFMOTD",
"description": "End of MOTD. RFC 2812 \u00a75.1.", "description": "End of MOTD. RFC 2812 \u00a75.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "title": "401 ERR_NOSUCHNICK",
"description": "No such nick/channel. RFC 1459 \u00a76.1.", "description": "No such nick/channel. RFC 1459 \u00a76.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "title": "403 ERR_NOSUCHCHANNEL",
"description": "No such channel. RFC 1459 \u00a76.1.", "description": "No such channel. RFC 1459 \u00a76.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "title": "433 ERR_NICKNAMEINUSE",
"description": "Nickname is already in use. RFC 1459 \u00a76.1.", "description": "Nickname is already in use. RFC 1459 \u00a76.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "title": "442 ERR_NOTONCHANNEL",
"description": "You're not on that channel. RFC 1459 \u00a76.1.", "description": "You're not on that channel. RFC 1459 \u00a76.1.",
"$ref": "../message.json", "$ref": "../message.json",

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "title": "482 ERR_CHANOPRIVSNEEDED",
"description": "You're not channel operator. RFC 1459 \u00a76.1.", "description": "You're not channel operator. RFC 1459 \u00a76.1.",
"$ref": "../message.json", "$ref": "../message.json",