fix: IRC SPA cleanup — /motd, /query, Firefox / key, default MOTD (#58)
All checks were successful
check / check (push) Successful in 1m0s
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:
@@ -13,6 +13,14 @@ import (
|
||||
_ "github.com/joho/godotenv/autoload" // loads .env file
|
||||
)
|
||||
|
||||
const defaultMOTD = ` _ __ ___ ___ (_)_ __ ___
|
||||
| '_ \ / _ \/ _ \ | | '__/ __|
|
||||
| | | | __/ (_) || | | | (__
|
||||
|_| |_|\___|\___/ |_|_| \___|
|
||||
|
||||
Welcome to NeoIRC — IRC semantics over HTTP.
|
||||
Type /help for available commands.`
|
||||
|
||||
// Params defines the dependencies for creating a Config.
|
||||
type Params struct {
|
||||
fx.In
|
||||
@@ -62,7 +70,7 @@ func New(
|
||||
viper.SetDefault("METRICS_PASSWORD", "")
|
||||
viper.SetDefault("MAX_HISTORY", "10000")
|
||||
viper.SetDefault("MAX_MESSAGE_SIZE", "4096")
|
||||
viper.SetDefault("MOTD", "")
|
||||
viper.SetDefault("MOTD", defaultMOTD)
|
||||
viper.SetDefault("SERVER_NAME", "")
|
||||
viper.SetDefault("FEDERATION_KEY", "")
|
||||
viper.SetDefault("SESSION_IDLE_TIMEOUT", "24h")
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user