feat: implement Tier 3 utility IRC commands (USERHOST, VERSION, ADMIN, INFO, TIME, KILL, WALLOPS) #96

Open
clawbot wants to merge 6 commits from feature/87-tier3-utility-commands into main
4 changed files with 320 additions and 213 deletions
Showing only changes of commit abe0cc2c30 - Show all commits

View File

@@ -1,14 +1,14 @@
package handlers package handlers
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "errors"
"net/http" "net/http"
"strings" "strings"
"time" "time"
"sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/pkg/irc" "sneak.berlin/go/neoirc/pkg/irc"
) )
@@ -471,7 +471,10 @@ func (hdlr *Handlers) handleWallops(
} }
// handleUserMode handles user mode queries and changes // handleUserMode handles user mode queries and changes
// (e.g., MODE nick, MODE nick +w). // (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( func (hdlr *Handlers) handleUserMode(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -496,86 +499,31 @@ func (hdlr *Handlers) handleUserMode(
return return
} }
hdlr.applyUserModeChange( newModes, err := hdlr.svc.ApplyUserMode(
writer, request, ctx, sessionID, lines[0],
sessionID, clientID, nick, lines[0],
) )
if err != nil {
return var ircErr *service.IRCError
} if errors.As(err, &ircErr) {
// Mode query — build the current mode string.
modeStr := hdlr.buildUserModeString(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)
}
// buildUserModeString constructs the mode string for a
// user (e.g., "+ow" for oper+wallops).
func (hdlr *Handlers) buildUserModeString(
ctx context.Context,
sessionID int64,
) string {
modes := "+"
isOper, err := hdlr.params.Database.IsSessionOper(
ctx, sessionID,
)
if err == nil && isOper {
modes += "o"
}
isWallops, err := hdlr.params.Database.IsSessionWallops(
ctx, sessionID,
)
if err == nil && isWallops {
modes += "w"
}
return modes
}
// applyUserModeChange applies a user mode change string
// (e.g., "+w", "-w").
func (hdlr *Handlers) applyUserModeChange(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, modeStr string,
) {
ctx := request.Context()
if len(modeStr) < 2 { //nolint:mnd // +/- and mode char
hdlr.respondIRCError( hdlr.respondIRCError(
writer, request, clientID, sessionID, writer, request,
irc.ErrUmodeUnknownFlag, nick, nil, clientID, sessionID,
"Unknown MODE flag", ircErr.Code, nick, ircErr.Params,
ircErr.Message,
) )
return return
} }
adding := modeStr[0] == '+' hdlr.respondError(
modeChar := modeStr[1:] writer, request,
"internal error",
applied, err := hdlr.applyModeChar( http.StatusInternalServerError,
ctx, writer, request,
sessionID, clientID, nick,
modeChar, adding,
) )
if err != nil || !applied {
return return
} }
newModes := hdlr.buildUserModeString(ctx, sessionID)
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplUmodeIs, nick, nil, ctx, clientID, irc.RplUmodeIs, nick, nil,
newModes, newModes,
@@ -585,75 +533,19 @@ func (hdlr *Handlers) applyUserModeChange(
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"}, map[string]string{"status": "ok"},
http.StatusOK) http.StatusOK)
}
return
// applyModeChar applies a single user mode character. }
// Returns (applied, error).
func (hdlr *Handlers) applyModeChar( // Mode query — delegate to shared service.
ctx context.Context, modeStr := hdlr.svc.QueryUserMode(ctx, sessionID)
writer http.ResponseWriter,
request *http.Request, hdlr.enqueueNumeric(
sessionID, clientID int64, ctx, clientID, irc.RplUmodeIs, nick, nil,
nick, modeChar string, modeStr,
adding bool, )
) (bool, error) { hdlr.broker.Notify(sessionID)
switch modeChar { hdlr.respondJSON(writer, request,
case "w": map[string]string{"status": "ok"},
err := hdlr.params.Database.SetSessionWallops( http.StatusOK)
ctx, sessionID, adding,
)
if err != nil {
hdlr.log.Error(
"set wallops mode failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return false, fmt.Errorf(
"set wallops: %w", err,
)
}
case "o":
// +o cannot be set via MODE, only via OPER.
if adding {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrUmodeUnknownFlag, nick, nil,
"Unknown MODE flag",
)
return false, nil
}
err := hdlr.params.Database.SetSessionOper(
ctx, sessionID, false,
)
if err != nil {
hdlr.log.Error(
"clear oper mode failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return false, fmt.Errorf(
"clear oper: %w", err,
)
}
default:
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrUmodeUnknownFlag, nick, nil,
"Unknown MODE flag",
)
return false, nil
}
return true, nil
} }

View File

@@ -730,17 +730,23 @@ func (c *Conn) handleUserMode(
// Mode query (no mode string). // Mode query (no mode string).
if len(msg.Params) < 2 { //nolint:mnd if len(msg.Params) < 2 { //nolint:mnd
c.sendNumeric( modes := c.svc.QueryUserMode(ctx, c.sessionID)
irc.RplUmodeIs, c.sendNumeric(irc.RplUmodeIs, modes)
c.buildUmodeString(ctx),
)
return return
} }
modeStr := msg.Params[1] 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
}
if len(modeStr) < 2 { //nolint:mnd
c.sendNumeric( c.sendNumeric(
irc.ErrUmodeUnknownFlag, irc.ErrUmodeUnknownFlag,
"Unknown MODE flag", "Unknown MODE flag",
@@ -749,64 +755,7 @@ func (c *Conn) handleUserMode(
return return
} }
adding := modeStr[0] == '+' c.sendNumeric(irc.RplUmodeIs, newModes)
for _, ch := range modeStr[1:] {
switch ch {
case 'w':
_ = c.database.SetSessionWallops(
ctx, c.sessionID, adding,
)
case 'o':
if adding {
c.sendNumeric(
irc.ErrUmodeUnknownFlag,
"Unknown MODE flag",
)
return
}
_ = c.database.SetSessionOper(
ctx, c.sessionID, false,
)
default:
c.sendNumeric(
irc.ErrUmodeUnknownFlag,
"Unknown MODE flag",
)
return
}
}
c.sendNumeric(
irc.RplUmodeIs,
c.buildUmodeString(ctx),
)
}
// buildUmodeString returns the current user mode string.
func (c *Conn) buildUmodeString(
ctx context.Context,
) string {
modes := "+"
isOper, err := c.database.IsSessionOper(
ctx, c.sessionID,
)
if err == nil && isOper {
modes += "o"
}
isWallops, err := c.database.IsSessionWallops(
ctx, c.sessionID,
)
if err == nil && isWallops {
modes += "w"
}
return modes
} }
// handleNames replies with channel member list. // handleNames replies with channel member list.

View File

@@ -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

@@ -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)
}
}