Add the tier 3 utility IRC commands (closes #87) #96

Merged
clawbot merged 2 commits from feature/87-tier3-utility-commands into next 2026-10-08 07:31:38 +02:00
8 changed files with 70 additions and 18 deletions
Showing only changes of commit c0eef92d23 - Show all commits

No files matched your search

+3 -2
View File
@@ -1951,8 +1951,9 @@ func (hdlr *Handlers) handleQuit(
http.StatusOK) http.StatusOK)
} }
// handleMode handles the MODE command for channels and // handleMode handles the MODE command: a target that starts
// users. Currently supports query-only (no mode changes). // with # is a channel, handled by handleChannelMode, and any
// other target a nick, handled by handleUserMode.
func (hdlr *Handlers) handleMode( func (hdlr *Handlers) handleMode(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
+5 -7
View File
@@ -106,22 +106,20 @@ func (hdlr *Handlers) handleAdmin(
) { ) {
ctx := request.Context() ctx := request.Context()
srvName := hdlr.serverName() srvName := hdlr.serverName()
location1, location2, email := hdlr.svc.AdminLines(srvName)
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminMe, nick, ctx, clientID, irc.RplAdminMe, nick,
[]string{srvName}, "Administrative info", []string{srvName}, "Administrative info",
) )
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminLoc1, nick, nil, ctx, clientID, irc.RplAdminLoc1, nick, nil, location1,
"neoirc server",
) )
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminLoc2, nick, nil, ctx, clientID, irc.RplAdminLoc2, nick, nil, location2,
"IRC over HTTP",
) )
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplAdminEmail, nick, nil, ctx, clientID, irc.RplAdminEmail, nick, nil, email,
"admin@"+srvName,
) )
hdlr.broker.Notify(sessionID) hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request, hdlr.respondJSON(writer, request,
@@ -199,7 +197,7 @@ func (hdlr *Handlers) handleKill(
return return
} }
reason := "KILLed" reason := ""
if len(lines) > 1 { if len(lines) > 1 {
reason = lines[1] reason = lines[1]
} }
+13 -2
View File
@@ -90,6 +90,16 @@ func TestUserhost(t *testing.T) {
t.Errorf("expected alice and bob, got %q", body) t.Errorf("expected alice and bob, got %q", body)
} }
msgs, lastID = sendAndPoll(tserver, token, lastID, map[string]any{
commandKey: irc.CmdUserhost,
bodyKey: []string{"ghost"},
})
body = numericBody(t, msgs, "302")
if body != "" {
t.Errorf("expected an empty reply for ghost, got %q", body)
}
msgs, _ = sendAndPoll(tserver, token, lastID, map[string]any{ msgs, _ = sendAndPoll(tserver, token, lastID, map[string]any{
commandKey: irc.CmdUserhost, commandKey: irc.CmdUserhost,
}) })
@@ -187,8 +197,8 @@ func TestKill(t *testing.T) {
commandKey: irc.CmdKill, commandKey: irc.CmdKill,
bodyKey: []string{"victim", "go away"}, bodyKey: []string{"victim", "go away"},
}) })
if status != http.StatusOK { if status != http.StatusOK || result[statusKey] != "ok" {
t.Fatalf("expected 200, got %d: %v", status, result) t.Fatalf("expected 200 and status ok, got %d: %v", status, result)
} }
msgs, _ = tserver.pollMessages(observerToken, observerLastID) msgs, _ = tserver.pollMessages(observerToken, observerLastID)
@@ -283,6 +293,7 @@ func TestUserMode(t *testing.T) {
{nick, "", "221", "+w"}, {nick, "", "221", "+w"},
{strings.ToUpper(nick), "-w", "221", "+"}, {strings.ToUpper(nick), "-w", "221", "+"},
{nick, "+z", "501", ""}, {nick, "+z", "501", ""},
{nick, "+o", "501", ""},
{other, "", "502", ""}, {other, "", "502", ""},
{other, "-w", "502", ""}, {other, "-w", "502", ""},
} { } {
+6 -4
View File
@@ -1313,12 +1313,14 @@ func (c *Conn) handleVersion() {
// handleAdmin replies with the server's admin info. // handleAdmin replies with the server's admin info.
func (c *Conn) handleAdmin() { func (c *Conn) handleAdmin() {
location1, location2, email := c.svc.AdminLines(c.serverSfx)
c.sendNumeric( c.sendNumeric(
irc.RplAdminMe, c.serverSfx, "Administrative info", irc.RplAdminMe, c.serverSfx, "Administrative info",
) )
c.sendNumeric(irc.RplAdminLoc1, "neoirc server") c.sendNumeric(irc.RplAdminLoc1, location1)
c.sendNumeric(irc.RplAdminLoc2, "IRC over HTTP") c.sendNumeric(irc.RplAdminLoc2, location2)
c.sendNumeric(irc.RplAdminEmail, "admin@"+c.serverSfx) c.sendNumeric(irc.RplAdminEmail, email)
} }
// handleInfo replies with the server's software info. // handleInfo replies with the server's software info.
@@ -1352,7 +1354,7 @@ func (c *Conn) handleKill(
return return
} }
reason := "KILLed" reason := ""
if len(msg.Params) > 1 { if len(msg.Params) > 1 {
reason = msg.Params[1] reason = msg.Params[1]
} }
+4 -2
View File
@@ -37,7 +37,9 @@ func newKillVictim(t *testing.T) (*ircserver.Conn, net.Conn) {
// TestDisconnectDoesNotBlockOnUnresponsiveVictim checks // TestDisconnectDoesNotBlockOnUnresponsiveVictim checks
// that Disconnect returns although the victim never reads, // that Disconnect returns although the victim never reads,
// so a KILL cannot stall the operator who sent it. // so a KILL cannot stall the operator who sent it. The
// limit is well below one KillWriteWindow, so a Disconnect
// that waits on any write to the victim fails.
func TestDisconnectDoesNotBlockOnUnresponsiveVictim( func TestDisconnectDoesNotBlockOnUnresponsiveVictim(
t *testing.T, t *testing.T,
) { ) {
@@ -53,7 +55,7 @@ func TestDisconnectDoesNotBlockOnUnresponsiveVictim(
select { select {
case <-returned: case <-returned:
case <-time.After(testTimeout): case <-time.After(ircserver.KillWriteWindow / 4):
t.Fatal("Disconnect waited for the victim to read") t.Fatal("Disconnect waited for the victim to read")
} }
} }
+4
View File
@@ -56,6 +56,10 @@ func (s *Server) Listener() net.Listener {
return s.listener return s.listener
} }
// KillWriteWindow is how long Disconnect allows each write
// to a killed victim.
const KillWriteWindow = killWriteWindow
// NewTestConn returns a Conn for nick on tcpConn, without a // NewTestConn returns a Conn for nick on tcpConn, without a
// server, database or service behind it. // server, database or service behind it.
func NewTestConn( func NewTestConn(
+15 -1
View File
@@ -119,6 +119,15 @@ func (s *Service) InfoLines() []string {
} }
} }
// AdminLines returns the texts of the RPL_ADMINLOC1,
// RPL_ADMINLOC2 and RPL_ADMINEMAIL replies, in that order,
// that ADMIN sends on both transports.
func (s *Service) AdminLines(
serverName string,
) (string, string, string) {
return "neoirc server", "IRC over HTTP", "admin@" + serverName
}
// UserhostReply returns the RPL_USERHOST text for the first // UserhostReply returns the RPL_USERHOST text for the first
// five of the given nicks: "nick=+user@host" entries joined // five of the given nicks: "nick=+user@host" entries joined
// by spaces, with * after the nick of an operator and - in // by spaces, with * after the nick of an operator and - in
@@ -199,7 +208,8 @@ func (s *Service) UnregisterWireConn(
// KillUser carries out an operator's KILL: the target's // KillUser carries out an operator's KILL: the target's
// channel peers see it quit, its session is deleted, and // channel peers see it quit, its session is deleted, and
// its IRC connection, if it has one, is closed. // its IRC connection, if it has one, is closed. An empty
// reason is given as "KILLed".
func (s *Service) KillUser( func (s *Service) KillUser(
ctx context.Context, ctx context.Context,
sessionID int64, sessionID int64,
@@ -231,6 +241,10 @@ func (s *Service) KillUser(
} }
} }
if reason == "" {
reason = "KILLed"
}
quitReason := "Killed (" + nick + " (" + reason + "))" quitReason := "Killed (" + nick + " (" + reason + "))"
s.BroadcastQuit(ctx, targetSID, targetNick, quitReason) s.BroadcastQuit(ctx, targetSID, targetNick, quitReason)
+20
View File
@@ -406,6 +406,16 @@ func TestQueryUserMode(t *testing.T) {
if modes != "+ow" { if modes != "+ow" {
t.Errorf("expected +ow, got %s", modes) t.Errorf("expected +ow, got %s", modes)
} }
// A flag that cannot be read is an error, not a flag
// that is unset.
cancelled, cancel := context.WithCancel(ctx)
cancel()
_, err = env.svc.QueryUserMode(cancelled, sid)
if !errors.Is(err, context.Canceled) {
t.Errorf("expected the read's error, got %v", err)
}
} }
func TestUserhostReply(t *testing.T) { func TestUserhostReply(t *testing.T) {
@@ -453,6 +463,16 @@ func TestUserhostReply(t *testing.T) {
if strings.Count(reply, "bob=") != 5 { if strings.Count(reply, "bob=") != 5 {
t.Errorf("want 5 entries for 6 nicks, got %q", reply) t.Errorf("want 5 entries for 6 nicks, got %q", reply)
} }
// A nick that cannot be looked up is an error, not a nick
// with no session.
cancelled, cancel := context.WithCancel(ctx)
cancel()
_, err = env.svc.UserhostReply(cancelled, []string{"alice"}, "srv")
if !errors.Is(err, context.Canceled) {
t.Errorf("want the lookup's error, got %v", err)
}
} }
// userModes is the stored state of a session's user mode // userModes is the stored state of a session's user mode