feat: implement Tier 3 utility IRC commands (USERHOST, VERSION, ADMIN, INFO, TIME, KILL, WALLOPS) #96
@@ -71,7 +71,17 @@ func New(
|
|||||||
lifecycle.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
OnStart: func(_ context.Context) error {
|
OnStart: func(_ context.Context) error {
|
||||||
srv.startupTime = time.Now()
|
srv.startupTime = time.Now()
|
||||||
go srv.Run() //nolint:contextcheck
|
// Configure, enable Sentry, and build the router
|
||||||
|
// synchronously so that srv.router is fully initialized
|
||||||
|
// before OnStart returns. Any HTTP traffic (including
|
||||||
|
// httptest harnesses that wrap srv as a handler) is
|
||||||
|
// therefore guaranteed to see an initialized router,
|
||||||
|
// eliminating the previous race between SetupRoutes
|
||||||
|
// and ServeHTTP.
|
||||||
|
srv.configure()
|
||||||
|
srv.enableSentry()
|
||||||
|
srv.SetupRoutes()
|
||||||
|
go srv.serve() //nolint:contextcheck
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -83,10 +93,16 @@ func New(
|
|||||||
return srv, nil
|
return srv, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run starts the server configuration, Sentry, and begins serving.
|
// Run configures the server and begins serving. It blocks
|
||||||
|
// until shutdown is signalled. Kept for external callers
|
||||||
|
// that embed the server outside fx. The fx lifecycle now
|
||||||
|
// performs setup synchronously in OnStart and invokes
|
||||||
|
// serve directly in a goroutine, so this is only used when
|
||||||
|
// the server is driven by hand.
|
||||||
func (srv *Server) Run() {
|
func (srv *Server) Run() {
|
||||||
srv.configure()
|
srv.configure()
|
||||||
srv.enableSentry()
|
srv.enableSentry()
|
||||||
|
srv.SetupRoutes()
|
||||||
srv.serve()
|
srv.serve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,8 +218,6 @@ func (srv *Server) serveUntilShutdown() {
|
|||||||
Handler: srv,
|
Handler: srv,
|
||||||
}
|
}
|
||||||
|
|
||||||
srv.SetupRoutes()
|
|
||||||
|
|
||||||
srv.log.Info(
|
srv.log.Info(
|
||||||
"http begin listen", "listenaddr", listenAddr,
|
"http begin listen", "listenaddr", listenAddr,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -814,28 +814,34 @@ func (s *Service) QueryUserMode(
|
|||||||
return modes
|
return modes
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyUserMode parses a mode string character by
|
// userModeOp is a single parsed user-mode change collected
|
||||||
// character (e.g. "+wo", "-w") and applies each mode
|
// by parseUserModeString before any DB writes happen.
|
||||||
// change to the session. Returns the resulting mode string
|
type userModeOp struct {
|
||||||
// after all changes, or an IRCError on failure.
|
char rune
|
||||||
|
adding bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyUserMode parses an IRC user-mode string and applies
|
||||||
|
// the resulting changes atomically. It supports multiple
|
||||||
|
// sign transitions (e.g. "+w-o", "-w+o", "+o-w+w") and
|
||||||
|
// rejects malformed input (empty string, no leading sign,
|
||||||
|
// bare sign with no mode letters, unknown mode letters,
|
||||||
|
// +o which must be set via OPER) with an IRCError. On
|
||||||
|
// failure, no persistent change is made. On success, the
|
||||||
|
// resulting mode string is returned.
|
||||||
func (s *Service) ApplyUserMode(
|
func (s *Service) ApplyUserMode(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sessionID int64,
|
sessionID int64,
|
||||||
modeStr string,
|
modeStr string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
if len(modeStr) < 2 { //nolint:mnd // +/- prefix + ≥1 char
|
ops, err := parseUserModeString(modeStr)
|
||||||
return "", &IRCError{
|
if err != nil {
|
||||||
Code: irc.ErrUmodeUnknownFlag,
|
return "", err
|
||||||
Params: nil,
|
|
||||||
Message: "Unknown MODE flag",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
adding := modeStr[0] == '+'
|
for _, op := range ops {
|
||||||
|
|
||||||
for _, ch := range modeStr[1:] {
|
|
||||||
if err := s.applySingleUserMode(
|
if err := s.applySingleUserMode(
|
||||||
ctx, sessionID, ch, adding,
|
ctx, sessionID, op.char, op.adding,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -844,7 +850,79 @@ func (s *Service) ApplyUserMode(
|
|||||||
return s.QueryUserMode(ctx, sessionID), nil
|
return s.QueryUserMode(ctx, sessionID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// applySingleUserMode applies one user mode character.
|
// parseUserModeString validates and parses a user-mode
|
||||||
|
// string into a list of operations. The string must begin
|
||||||
|
// with '+' or '-'; subsequent '+' / '-' characters flip the
|
||||||
|
// active sign, and letters between them are applied with
|
||||||
|
// the current sign. Every letter must be a recognized user
|
||||||
|
// mode for this server, and '+o' is never allowed via MODE
|
||||||
|
// (use OPER to become operator). If any character is
|
||||||
|
// invalid, no operations are returned and an IRCError with
|
||||||
|
// ERR_UMODEUNKNOWNFLAG (501) is returned.
|
||||||
|
func parseUserModeString(
|
||||||
|
modeStr string,
|
||||||
|
) ([]userModeOp, error) {
|
||||||
|
unknownFlag := &IRCError{
|
||||||
|
Code: irc.ErrUmodeUnknownFlag,
|
||||||
|
Params: nil,
|
||||||
|
Message: "Unknown MODE flag",
|
||||||
|
}
|
||||||
|
|
||||||
|
if modeStr == "" {
|
||||||
|
return nil, unknownFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
first := modeStr[0]
|
||||||
|
if first != '+' && first != '-' {
|
||||||
|
return nil, unknownFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
ops := make([]userModeOp, 0, len(modeStr)-1)
|
||||||
|
adding := true
|
||||||
|
|
||||||
|
for _, ch := range modeStr {
|
||||||
|
switch ch {
|
||||||
|
case '+':
|
||||||
|
adding = true
|
||||||
|
case '-':
|
||||||
|
adding = false
|
||||||
|
default:
|
||||||
|
if !isKnownUserModeChar(ch) {
|
||||||
|
return nil, unknownFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch == 'o' && adding {
|
||||||
|
return nil, unknownFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
ops = append(ops, userModeOp{
|
||||||
|
char: ch, adding: adding,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ops) == 0 {
|
||||||
|
return nil, unknownFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
return ops, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isKnownUserModeChar reports whether the character is a
|
||||||
|
// recognized user mode letter.
|
||||||
|
func isKnownUserModeChar(ch rune) bool {
|
||||||
|
switch ch {
|
||||||
|
case 'w', 'o':
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applySingleUserMode applies one already-validated user
|
||||||
|
// mode character to the session. parseUserModeString must
|
||||||
|
// have validated the character and sign before this runs;
|
||||||
|
// the default branch here is defence-in-depth only.
|
||||||
func (s *Service) applySingleUserMode(
|
func (s *Service) applySingleUserMode(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sessionID int64,
|
sessionID int64,
|
||||||
@@ -864,7 +942,6 @@ func (s *Service) applySingleUserMode(
|
|||||||
return fmt.Errorf("set wallops: %w", err)
|
return fmt.Errorf("set wallops: %w", err)
|
||||||
}
|
}
|
||||||
case 'o':
|
case 'o':
|
||||||
// +o cannot be set via MODE; use OPER command.
|
|
||||||
if adding {
|
if adding {
|
||||||
return &IRCError{
|
return &IRCError{
|
||||||
Code: irc.ErrUmodeUnknownFlag,
|
Code: irc.ErrUmodeUnknownFlag,
|
||||||
|
|||||||
@@ -393,136 +393,260 @@ func TestQueryUserMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyUserModeSingleChar(t *testing.T) {
|
// TestApplyUserMode is the rigorous table-driven suite for
|
||||||
env := newTestEnv(t)
|
// the shared user-mode parser. It covers every case listed
|
||||||
ctx := t.Context()
|
// in sneak's review of PR #96 plus a few adjacent ones.
|
||||||
|
// Each case asserts the resulting mode string AND the
|
||||||
sid := createSession(ctx, t, env.db, "alice")
|
// persisted session state, to prove that rejected input
|
||||||
|
// leaves no side effects.
|
||||||
// Apply +w.
|
func TestApplyUserMode(t *testing.T) {
|
||||||
result, err := env.svc.ApplyUserMode(ctx, sid, "+w")
|
type caseState struct {
|
||||||
if err != nil {
|
oper bool
|
||||||
t.Fatalf("apply +w: %v", err)
|
wallops bool
|
||||||
}
|
}
|
||||||
|
|
||||||
if result != "+w" {
|
// Each case starts from initialState, calls
|
||||||
t.Errorf("expected +w, got %s", result)
|
// ApplyUserMode(modeStr), and verifies either the
|
||||||
|
// returned mode string (wantModes) or an IRCError with
|
||||||
|
// wantErrCode. After the call, the DB must match
|
||||||
|
// wantState — proving that rejected input made no
|
||||||
|
// change.
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
initialState caseState
|
||||||
|
modeStr string
|
||||||
|
wantModes string
|
||||||
|
wantErr bool
|
||||||
|
wantErrCode irc.IRCMessageType
|
||||||
|
wantState caseState
|
||||||
|
}{
|
||||||
|
// Happy path: single-char operations.
|
||||||
|
{
|
||||||
|
name: "+w from empty",
|
||||||
|
modeStr: "+w",
|
||||||
|
wantModes: "+w",
|
||||||
|
wantState: caseState{wallops: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "-w from +w",
|
||||||
|
initialState: caseState{wallops: true},
|
||||||
|
modeStr: "-w",
|
||||||
|
wantModes: "+",
|
||||||
|
wantState: caseState{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "-o from +o",
|
||||||
|
initialState: caseState{oper: true},
|
||||||
|
modeStr: "-o",
|
||||||
|
wantModes: "+",
|
||||||
|
wantState: caseState{},
|
||||||
|
},
|
||||||
|
// Multi-char without sign transitions.
|
||||||
|
{
|
||||||
|
name: "-wo from +ow",
|
||||||
|
initialState: caseState{oper: true, wallops: true},
|
||||||
|
modeStr: "-wo",
|
||||||
|
wantModes: "+",
|
||||||
|
wantState: caseState{},
|
||||||
|
},
|
||||||
|
// Multi-char with sign transitions.
|
||||||
|
{
|
||||||
|
name: "+w-o from +o",
|
||||||
|
initialState: caseState{oper: true},
|
||||||
|
modeStr: "+w-o",
|
||||||
|
wantModes: "+w",
|
||||||
|
wantState: caseState{wallops: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "-w+o always rejects +o",
|
||||||
|
initialState: caseState{wallops: true, oper: true},
|
||||||
|
modeStr: "-w+o",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||||
|
// +o is rejected before any op applies; wallops
|
||||||
|
// stays set.
|
||||||
|
wantState: caseState{wallops: true, oper: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "+o-w+w rejects because of +o",
|
||||||
|
initialState: caseState{wallops: true, oper: true},
|
||||||
|
modeStr: "+o-w+w",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||||
|
// Wallops must NOT be cleared; oper must NOT be
|
||||||
|
// cleared. Rejection is fully atomic.
|
||||||
|
wantState: caseState{wallops: true, oper: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "-x+y rejects unknown -x",
|
||||||
|
modeStr: "-x+y",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||||
|
wantState: caseState{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "+y-x rejects unknown +y",
|
||||||
|
modeStr: "+y-x",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||||
|
wantState: caseState{},
|
||||||
|
},
|
||||||
|
// Malformed prefix — must not silently treat as '-'.
|
||||||
|
{
|
||||||
|
name: "w no prefix rejects",
|
||||||
|
modeStr: "w",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||||
|
wantState: caseState{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "xw no prefix rejects (would have been" +
|
||||||
|
" silently -w before)",
|
||||||
|
initialState: caseState{wallops: true},
|
||||||
|
modeStr: "xw",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||||
|
// Prove wallops is NOT cleared — the whole point
|
||||||
|
// of sneak's review.
|
||||||
|
wantState: caseState{wallops: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string rejects",
|
||||||
|
modeStr: "",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||||
|
wantState: caseState{},
|
||||||
|
},
|
||||||
|
// Bare signs with no mode letters reject.
|
||||||
|
{
|
||||||
|
name: "bare + rejects",
|
||||||
|
modeStr: "+",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||||
|
wantState: caseState{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare - rejects",
|
||||||
|
modeStr: "-",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||||
|
wantState: caseState{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "+-+ rejects (no mode letters)",
|
||||||
|
modeStr: "+-+",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||||
|
wantState: caseState{},
|
||||||
|
},
|
||||||
|
// Unknown mode letters reject whole string.
|
||||||
|
{
|
||||||
|
name: "+z unknown mode rejects",
|
||||||
|
modeStr: "+z",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||||
|
wantState: caseState{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "+wz rejects whole thing; +w side effect" +
|
||||||
|
" must NOT persist",
|
||||||
|
modeStr: "+wz",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||||
|
// Wallops must NOT be set.
|
||||||
|
wantState: caseState{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "+wo rejects whole thing; +w side effect" +
|
||||||
|
" must NOT persist",
|
||||||
|
modeStr: "+wo",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||||
|
wantState: caseState{},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply -w.
|
for _, tc := range cases {
|
||||||
result, err = env.svc.ApplyUserMode(ctx, sid, "-w")
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
if err != nil {
|
env := newTestEnv(t)
|
||||||
t.Fatalf("apply -w: %v", err)
|
ctx := t.Context()
|
||||||
}
|
sid := createSession(ctx, t, env.db, "alice")
|
||||||
|
|
||||||
if result != "+" {
|
if tc.initialState.oper {
|
||||||
t.Errorf("expected +, got %s", result)
|
if err := env.db.SetSessionOper(
|
||||||
}
|
ctx, sid, true,
|
||||||
}
|
); err != nil {
|
||||||
|
t.Fatalf("init oper: %v", err)
|
||||||
func TestApplyUserModeMultiChar(t *testing.T) {
|
}
|
||||||
env := newTestEnv(t)
|
}
|
||||||
ctx := t.Context()
|
|
||||||
|
if tc.initialState.wallops {
|
||||||
sid := createSession(ctx, t, env.db, "alice")
|
if err := env.db.SetSessionWallops(
|
||||||
|
ctx, sid, true,
|
||||||
// Set oper first so we can test +wo (w applied, o
|
); err != nil {
|
||||||
// rejected because +o is not allowed via MODE).
|
t.Fatalf("init wallops: %v", err)
|
||||||
_ = env.db.SetSessionOper(ctx, sid, true)
|
}
|
||||||
|
}
|
||||||
// Apply +w alone should work.
|
|
||||||
result, err := env.svc.ApplyUserMode(ctx, sid, "+w")
|
result, err := env.svc.ApplyUserMode(
|
||||||
if err != nil {
|
ctx, sid, tc.modeStr,
|
||||||
t.Fatalf("apply +w: %v", err)
|
)
|
||||||
}
|
|
||||||
|
if tc.wantErr {
|
||||||
if result != "+ow" {
|
var ircErr *service.IRCError
|
||||||
t.Errorf("expected +ow, got %s", result)
|
if !errors.As(err, &ircErr) {
|
||||||
}
|
t.Fatalf(
|
||||||
|
"expected IRCError, got %v", err,
|
||||||
// Reset wallops.
|
)
|
||||||
_ = env.db.SetSessionWallops(ctx, sid, false)
|
}
|
||||||
|
|
||||||
// Multi-char -ow: should de-oper and remove wallops.
|
if ircErr.Code != tc.wantErrCode {
|
||||||
_ = env.db.SetSessionWallops(ctx, sid, true)
|
t.Errorf(
|
||||||
|
"code: want %d got %d",
|
||||||
result, err = env.svc.ApplyUserMode(ctx, sid, "-ow")
|
tc.wantErrCode, ircErr.Code,
|
||||||
if err != nil {
|
)
|
||||||
t.Fatalf("apply -ow: %v", err)
|
}
|
||||||
}
|
} else {
|
||||||
|
if err != nil {
|
||||||
if result != "+" {
|
t.Fatalf("unexpected error: %v", err)
|
||||||
t.Errorf("expected +, got %s", result)
|
}
|
||||||
}
|
|
||||||
|
if result != tc.wantModes {
|
||||||
// +wo should fail because +o is not allowed.
|
t.Errorf(
|
||||||
_, err = env.svc.ApplyUserMode(ctx, sid, "+wo")
|
"modes: want %q got %q",
|
||||||
|
tc.wantModes, result,
|
||||||
var ircErr *service.IRCError
|
)
|
||||||
if !errors.As(err, &ircErr) {
|
}
|
||||||
t.Fatalf("expected IRCError, got %v", err)
|
}
|
||||||
}
|
|
||||||
|
// Whether or not the call errored, the persisted
|
||||||
if ircErr.Code != irc.ErrUmodeUnknownFlag {
|
// state must match wantState — this is the
|
||||||
t.Errorf(
|
// atomicity guarantee sneak demanded.
|
||||||
"expected ErrUmodeUnknownFlag, got %d",
|
gotOper, err := env.db.IsSessionOper(ctx, sid)
|
||||||
ircErr.Code,
|
if err != nil {
|
||||||
)
|
t.Fatalf("read oper: %v", err)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
gotWallops, err := env.db.IsSessionWallops(
|
||||||
func TestApplyUserModeInvalidInput(t *testing.T) {
|
ctx, sid,
|
||||||
env := newTestEnv(t)
|
)
|
||||||
ctx := t.Context()
|
if err != nil {
|
||||||
|
t.Fatalf("read wallops: %v", err)
|
||||||
sid := createSession(ctx, t, env.db, "alice")
|
}
|
||||||
|
|
||||||
// Too short.
|
if gotOper != tc.wantState.oper {
|
||||||
_, err := env.svc.ApplyUserMode(ctx, sid, "+")
|
t.Errorf(
|
||||||
|
"oper: want %v got %v",
|
||||||
var ircErr *service.IRCError
|
tc.wantState.oper, gotOper,
|
||||||
if !errors.As(err, &ircErr) {
|
)
|
||||||
t.Fatalf("expected IRCError for short input, got %v", err)
|
}
|
||||||
}
|
|
||||||
|
if gotWallops != tc.wantState.wallops {
|
||||||
// Unknown flag.
|
t.Errorf(
|
||||||
_, err = env.svc.ApplyUserMode(ctx, sid, "+x")
|
"wallops: want %v got %v",
|
||||||
if !errors.As(err, &ircErr) {
|
tc.wantState.wallops, gotWallops,
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user