feat: add /list, /who, /whois commands + CLI parity
All checks were successful
check / check (push) Successful in 2m18s

- Server: add LIST, WHO, WHOIS command handlers in dispatchCommand
  with proper IRC numerics (322/323, 352/315, 311/312/319/318)
- SPA: add /list, /who, /whois command parsing and numeric display
- CLI: add /motd, /who, /whois commands for feature parity
- Rebuild SPA dist from source
This commit is contained in:
user
2026-03-09 14:56:11 -07:00
parent a55127ff7c
commit a48c7f562c
5 changed files with 536 additions and 24 deletions

View File

@@ -138,16 +138,29 @@ func (a *App) dispatchCommand(cmd, args string) {
a.cmdQuery(args)
case "/topic":
a.cmdTopic(args)
case "/names":
a.cmdNames()
case "/list":
a.cmdList()
case "/window", "/w":
a.cmdWindow(args)
case "/quit":
a.cmdQuit()
case "/help":
a.cmdHelp()
default:
a.dispatchInfoCommand(cmd, args)
}
}
func (a *App) dispatchInfoCommand(cmd, args string) {
switch cmd {
case "/names":
a.cmdNames()
case "/list":
a.cmdList()
case "/motd":
a.cmdMotd()
case "/who":
a.cmdWho(args)
case "/whois":
a.cmdWhois(args)
default:
a.ui.AddStatus(
"[red]Unknown command: " + cmd,
@@ -510,6 +523,96 @@ func (a *App) cmdList() {
a.ui.AddStatus("[cyan]*** End of channel list")
}
func (a *App) cmdMotd() {
a.mu.Lock()
connected := a.connected
a.mu.Unlock()
if !connected {
a.ui.AddStatus("[red]Not connected")
return
}
err := a.client.SendMessage(
&api.Message{Command: "MOTD"}, //nolint:exhaustruct
)
if err != nil {
a.ui.AddStatus(fmt.Sprintf(
"[red]MOTD failed: %v", err,
))
}
}
func (a *App) cmdWho(args string) {
a.mu.Lock()
connected := a.connected
target := a.target
a.mu.Unlock()
if !connected {
a.ui.AddStatus("[red]Not connected")
return
}
channel := args
if channel == "" {
channel = target
}
if channel == "" ||
!strings.HasPrefix(channel, "#") {
a.ui.AddStatus(
"[red]Usage: /who #channel",
)
return
}
err := a.client.SendMessage(
&api.Message{ //nolint:exhaustruct
Command: "WHO", To: channel,
},
)
if err != nil {
a.ui.AddStatus(fmt.Sprintf(
"[red]WHO failed: %v", err,
))
}
}
func (a *App) cmdWhois(args string) {
a.mu.Lock()
connected := a.connected
a.mu.Unlock()
if !connected {
a.ui.AddStatus("[red]Not connected")
return
}
if args == "" {
a.ui.AddStatus(
"[red]Usage: /whois <nick>",
)
return
}
err := a.client.SendMessage(
&api.Message{ //nolint:exhaustruct
Command: "WHOIS", To: args,
},
)
if err != nil {
a.ui.AddStatus(fmt.Sprintf(
"[red]WHOIS failed: %v", err,
))
}
}
func (a *App) cmdWindow(args string) {
if args == "" {
a.ui.AddStatus(
@@ -574,6 +677,9 @@ func (a *App) cmdHelp() {
" /topic [text] — View/set topic",
" /names — List channel members",
" /list — List channels",
" /who [#channel] — List users in channel",
" /whois <nick> — Show user info",
" /motd — Show message of the day",
" /window <n> — Switch buffer",
" /quit — Disconnect and exit",
" /help — This help",

View File

@@ -670,20 +670,12 @@ func (hdlr *Handlers) dispatchCommand(
hdlr.handleQuit(
writer, request, sessionID, nick, body,
)
case "MOTD":
hdlr.deliverMOTD(
request, clientID, sessionID, nick,
case "MOTD", "LIST", "WHO", "WHOIS", "PING":
hdlr.dispatchInfoCommand(
writer, request,
sessionID, clientID, nick,
command, target, bodyLines,
)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
case "PING":
hdlr.respondJSON(writer, request,
map[string]string{
"command": "PONG",
"from": hdlr.serverName(),
},
http.StatusOK)
default:
hdlr.enqueueNumeric(
request.Context(), clientID,
@@ -1398,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,

4
web/dist/app.js vendored

File diff suppressed because one or more lines are too long

10
web/dist/style.css vendored
View File

@@ -70,14 +70,14 @@ body,
}
.login-box .motd {
color: var(--text-dim);
font-size: 12px;
color: var(--accent);
font-size: 11px;
margin-bottom: 20px;
text-align: left;
white-space: pre-wrap;
white-space: pre;
font-family: inherit;
border-left: 2px solid var(--border);
padding-left: 12px;
line-height: 1.2;
overflow-x: auto;
}
.login-box form {

View File

@@ -419,6 +419,100 @@ function App() {
break;
}
case "322": {
// RPL_LIST — channel, member count, topic.
if (Array.isArray(msg.params) && msg.params.length >= 2) {
const chName = msg.params[0];
const count = msg.params[1];
const chTopic = body || "";
addMessage("Server", {
...base,
text: `${chName} (${count} users): ${chTopic.trim()}`,
system: true,
});
}
break;
}
case "323":
addMessage("Server", {
...base,
text: body || "End of channel list",
system: true,
});
break;
case "352": {
// RPL_WHOREPLY — channel, user, host, server, nick, flags.
if (Array.isArray(msg.params) && msg.params.length >= 5) {
const whoCh = msg.params[0];
const whoNick = msg.params[4];
const whoFlags = msg.params.length > 5 ? msg.params[5] : "";
addMessage("Server", {
...base,
text: `${whoCh} ${whoNick} ${whoFlags}`,
system: true,
});
}
break;
}
case "315":
addMessage("Server", {
...base,
text: body || "End of /WHO list",
system: true,
});
break;
case "311": {
// RPL_WHOISUSER — nick, user, host, *, realname.
if (Array.isArray(msg.params) && msg.params.length >= 1) {
const wiNick = msg.params[0];
addMessage("Server", {
...base,
text: `${wiNick} (${body})`,
system: true,
});
}
break;
}
case "312": {
// RPL_WHOISSERVER — nick, server, server info.
if (Array.isArray(msg.params) && msg.params.length >= 2) {
const wiNick = msg.params[0];
const wiServer = msg.params[1];
addMessage("Server", {
...base,
text: `${wiNick} on ${wiServer}`,
system: true,
});
}
break;
}
case "319": {
// RPL_WHOISCHANNELS — nick, channels.
if (Array.isArray(msg.params) && msg.params.length >= 1) {
const wiNick = msg.params[0];
addMessage("Server", {
...base,
text: `${wiNick} is on: ${body}`,
system: true,
});
}
break;
}
case "318":
addMessage("Server", {
...base,
text: body || "End of /WHOIS list",
system: true,
});
break;
case "375":
case "372":
case "376":
@@ -873,6 +967,60 @@ function App() {
break;
}
case "/list": {
try {
await api("/messages", {
method: "POST",
body: JSON.stringify({ command: "LIST" }),
});
} catch (err) {
addSystemMessage(
"Server",
`Failed to list channels: ${err.data?.error || "error"}`,
);
}
break;
}
case "/who": {
const whoTarget = parts[1] || (tab.type === "channel" ? tab.name : "");
if (whoTarget) {
try {
await api("/messages", {
method: "POST",
body: JSON.stringify({ command: "WHO", to: whoTarget }),
});
} catch (err) {
addSystemMessage(
"Server",
`WHO failed: ${err.data?.error || "error"}`,
);
}
} else {
addSystemMessage("Server", "Usage: /who #channel");
}
break;
}
case "/whois": {
if (parts[1]) {
try {
await api("/messages", {
method: "POST",
body: JSON.stringify({ command: "WHOIS", to: parts[1] }),
});
} catch (err) {
addSystemMessage(
"Server",
`WHOIS failed: ${err.data?.error || "error"}`,
);
}
} else {
addSystemMessage("Server", "Usage: /whois <nick>");
}
break;
}
case "/clear": {
const clearTarget = tab.name;
setMessages((prev) => ({ ...prev, [clearTarget]: [] }));
@@ -891,6 +1039,9 @@ function App() {
" /topic [text] — View or set channel topic",
" /mode +/-flags — Set channel modes",
" /motd — Display the message of the day",
" /list — List all channels",
" /who [#channel] — List users in a channel",
" /whois nick — Show info about a user",
" /clear — Clear messages in the current tab",
" /quit [reason] — Disconnect from server",
" /help — Show this help",