fix: IRC SPA cleanup — /motd, /query, Firefox / key, default MOTD (#58)
All checks were successful
check / check (push) Successful in 1m0s

## Summary

Fixes IRC client SPA issues reported in [issue #57](#57).

## Changes

### Server-side
- **Default MOTD**: Added figlet-style ASCII art MOTD for "neoirc" as the default when no MOTD is configured via environment/config
- **MOTD command handler**: Added `MOTD` case to `dispatchCommand` so clients can re-request the MOTD at any time (proper IRC behavior)

### SPA (web client)
- **`/motd` command**: Sends MOTD request to server, displays 375/372/376 numerics in server window
- **`/query nick [message]`**: Opens a DM tab with the specified user, optionally sends a message
- **`/clear`**: Clears messages in the current tab
- **Firefox `/` key fix**: Added global `keydown` listener that captures `/` when input is not focused, preventing Firefox quick search and redirecting focus to the input element. Also auto-focuses input on SPA init.
- **MOTD on resumed sessions**: When restoring from a saved token, the MOTD is re-requested so it always appears in the server window
- **Updated `/help`**: Shows all new commands with descriptions
- **Login screen MOTD styling**: Improved for ASCII art display (monospace, proper line height)

## Testing
- `docker build .` passes (includes `make check` with tests, lint, fmt-check)
- All existing tests pass with no modifications

closes #57

<!-- session: agent:sdlc-manager:subagent:7c880fec-f818-49ff-a548-2d3c26758bb6 -->

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #58
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
This commit was merged in pull request #58.
This commit is contained in:
2026-03-09 23:00:34 +01:00
committed by Jeffrey Paul
parent f8f0b6afbb
commit 47fb089969
7 changed files with 653 additions and 35 deletions

View File

@@ -670,13 +670,12 @@ func (hdlr *Handlers) dispatchCommand(
hdlr.handleQuit(
writer, request, sessionID, nick, body,
)
case "PING":
hdlr.respondJSON(writer, request,
map[string]string{
"command": "PONG",
"from": hdlr.serverName(),
},
http.StatusOK)
case "MOTD", "LIST", "WHO", "WHOIS", "PING":
hdlr.dispatchInfoCommand(
writer, request,
sessionID, clientID, nick,
command, target, bodyLines,
)
default:
hdlr.enqueueNumeric(
request.Context(), clientID,
@@ -1391,6 +1390,269 @@ func (hdlr *Handlers) executeTopic(
http.StatusOK)
}
// dispatchInfoCommand handles informational IRC commands
// that produce server-side numerics (MOTD, LIST, WHO,
// WHOIS, PING).
func (hdlr *Handlers) dispatchInfoCommand(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, command, target string,
bodyLines func() []string,
) {
okResp := map[string]string{"status": "ok"}
switch command {
case "MOTD":
hdlr.deliverMOTD(
request, clientID, sessionID, nick,
)
case "LIST":
hdlr.handleListCmd(
request, clientID, sessionID, nick,
)
case "WHO":
hdlr.handleWhoCmd(
request, clientID, sessionID, nick,
target,
)
case "WHOIS":
hdlr.handleWhoisCmd(
request, clientID, sessionID, nick,
target, bodyLines,
)
case "PING":
hdlr.respondJSON(writer, request,
map[string]string{
"command": "PONG",
"from": hdlr.serverName(),
},
http.StatusOK)
return
}
hdlr.respondJSON(
writer, request, okResp, http.StatusOK,
)
}
// handleListCmd sends RPL_LIST (322) for each channel,
// then sends 323 to signal the end of the list.
func (hdlr *Handlers) handleListCmd(
request *http.Request,
clientID, sessionID int64,
nick string,
) {
ctx := request.Context()
channels, err := hdlr.params.Database.ListAllChannels(
ctx,
)
if err != nil {
hdlr.enqueueNumeric(
ctx, clientID, "323", nick, nil,
"End of /LIST",
)
hdlr.broker.Notify(sessionID)
return
}
for _, channel := range channels {
memberIDs, _ :=
hdlr.params.Database.GetChannelMemberIDs(
ctx, channel.ID,
)
count := strconv.Itoa(len(memberIDs))
topic := channel.Topic
if topic == "" {
topic = " "
}
hdlr.enqueueNumeric(
ctx, clientID, "322", nick,
[]string{channel.Name, count}, topic,
)
}
hdlr.enqueueNumeric(
ctx, clientID, "323", nick, nil,
"End of /LIST",
)
hdlr.broker.Notify(sessionID)
}
// handleWhoCmd sends RPL_WHOREPLY (352) for each member
// of the target channel, followed by RPL_ENDOFWHO (315).
func (hdlr *Handlers) handleWhoCmd(
request *http.Request,
clientID, sessionID int64,
nick, target string,
) {
ctx := request.Context()
if target == "" {
hdlr.enqueueNumeric(
ctx, clientID, "461", nick,
[]string{"WHO"}, "Not enough parameters",
)
hdlr.broker.Notify(sessionID)
return
}
channel := target
if !strings.HasPrefix(channel, "#") {
channel = "#" + channel
}
chID, err := hdlr.params.Database.GetChannelByName(
ctx, channel,
)
if err != nil {
hdlr.enqueueNumeric(
ctx, clientID, "315", nick,
[]string{channel}, "End of /WHO list",
)
hdlr.broker.Notify(sessionID)
return
}
members, err := hdlr.params.Database.ChannelMembers(
ctx, chID,
)
if err == nil {
srvName := hdlr.serverName()
for _, mem := range members {
hdlr.enqueueNumeric(
ctx, clientID, "352", nick,
[]string{
channel, mem.Nick, "neoirc",
srvName, mem.Nick, "H",
},
"0 "+mem.Nick,
)
}
}
hdlr.enqueueNumeric(
ctx, clientID, "315", nick,
[]string{channel}, "End of /WHO list",
)
hdlr.broker.Notify(sessionID)
}
// handleWhoisCmd sends WHOIS reply numerics (311, 312,
// 319, 318) for the target nick.
func (hdlr *Handlers) handleWhoisCmd(
request *http.Request,
clientID, sessionID int64,
nick, target string,
bodyLines func() []string,
) {
ctx := request.Context()
whoisNick := target
if whoisNick == "" {
lines := bodyLines()
if len(lines) > 0 {
whoisNick = strings.TrimSpace(lines[0])
}
}
if whoisNick == "" {
hdlr.enqueueNumeric(
ctx, clientID, "461", nick,
[]string{"WHOIS"}, "Not enough parameters",
)
hdlr.broker.Notify(sessionID)
return
}
targetSID, err :=
hdlr.params.Database.GetSessionByNick(
ctx, whoisNick,
)
if err != nil {
hdlr.enqueueNumeric(
ctx, clientID, "401", nick,
[]string{whoisNick},
"No such nick/channel",
)
hdlr.enqueueNumeric(
ctx, clientID, "318", nick,
[]string{whoisNick},
"End of /WHOIS list",
)
hdlr.broker.Notify(sessionID)
return
}
hdlr.sendWhoisNumerics(
ctx, clientID, sessionID, nick,
whoisNick, targetSID,
)
}
// sendWhoisNumerics emits 311/312/319/318 for a
// resolved WHOIS target.
func (hdlr *Handlers) sendWhoisNumerics(
ctx context.Context,
clientID, sessionID int64,
nick, whoisNick string,
targetSID int64,
) {
srvName := hdlr.serverName()
// 311 RPL_WHOISUSER
hdlr.enqueueNumeric(
ctx, clientID, "311", nick,
[]string{whoisNick, whoisNick, "neoirc", "*"},
whoisNick,
)
// 312 RPL_WHOISSERVER
hdlr.enqueueNumeric(
ctx, clientID, "312", nick,
[]string{whoisNick, srvName},
srvName,
)
// 319 RPL_WHOISCHANNELS
channels, _ := hdlr.params.Database.GetSessionChannels(
ctx, targetSID,
)
if len(channels) > 0 {
names := make([]string, 0, len(channels))
for _, chanInfo := range channels {
names = append(names, chanInfo.Name)
}
hdlr.enqueueNumeric(
ctx, clientID, "319", nick,
[]string{whoisNick},
strings.Join(names, " "),
)
}
// 318 RPL_ENDOFWHOIS
hdlr.enqueueNumeric(
ctx, clientID, "318", nick,
[]string{whoisNick},
"End of /WHOIS list",
)
hdlr.broker.Notify(sessionID)
}
func (hdlr *Handlers) handleQuit(
writer http.ResponseWriter,
request *http.Request,