Every command that can be typed here, in one list

The command menu asked Discord and nothing else, so on IRC - which has
twenty-two commands of its own, and where people actually type them -
pressing slash offered nothing at all.

The commands belong to the daemon: each backend parses them out of a
message that starts with one, so the table lives beside them and the
matching happens there too. A client holding its own copy would be a second
list to keep in step, and it would be the one that drifted.

Matched as a subsequence rather than a prefix, because people type the
letters they remember - "dv" finds devoice the way it does in an editor -
and ranked by the three things somebody typing has in mind: the letters
being together, being at the start, and the name being short enough that
there was not much else it could have been.

Each row carries what the command takes, in the notation a manual page
uses. Discord's own commands are assembled into the same shape from the
arguments they declare, so a bot's command and this daemon's read alike.

Verified against a live IRC channel: "ki" finds kick, "dv" finds devoice,
"me" finds me before mode, nothing typed offers all twenty-two, and
"zzz" offers none. Kick and Discord correctly offer no built-ins, because
neither backend parses any.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-05 19:32:23 -04:00
co-authored by Claude Opus 5
parent 89b180ebd6
commit d37a795f22
4 changed files with 247 additions and 1 deletions
+25
View File
@@ -2980,6 +2980,31 @@ pub async fn list_commands(state: &AppState, account_id: &str, buffer_id: &str,
Ok(json!({ "commands": commands }))
}
/// What goes after a command's name, written the way a manual page would.
///
/// Built from the command's own description of its arguments, because that is
/// the only place it exists: `/wordle` and `/ban somebody [reason]` are the
/// same kind of line to a menu, and one of them has to be assembled.
pub fn command_usage(command: &Value) -> String {
let Some(options) = command["options"].as_array() else { return String::new() };
options
.iter()
// Subcommands and groups are a menu of their own rather than an
// argument, and writing them as one would promise something this
// client cannot yet do.
.filter(|o| !matches!(o["type"].as_i64(), Some(1) | Some(2)))
.map(|o| {
let name = o["name"].as_str().unwrap_or("arg");
if o["required"].as_bool().unwrap_or(false) {
format!("<{name}>")
} else {
format!("[{name}]")
}
})
.collect::<Vec<_>>()
.join(" ")
}
/// Runs a slash command, or presses something on a message.
///
/// One function because Discord has one endpoint: an interaction says what
+158
View File
@@ -0,0 +1,158 @@
//! The slash commands this daemon understands, and how to find one.
//!
//! Here rather than in a window because the commands are the daemon's: each
//! backend parses the ones it implements out of a message that starts with a
//! slash (see `backend/irc.rs`'s `send_message` and `backend/matrix/mod.rs`),
//! so this table is the same list read from the other end. A client that held
//! its own copy would be a second list to keep in step, and it would be the
//! one that drifted.
//!
//! Application commands - Discord's, belonging to whatever bots a server has
//! installed - are not here. Those come from the service, per channel, and are
//! merged with these by the RPC that answers the question.
/// One command somebody can type, as a menu needs to describe it.
pub struct Builtin {
pub name: &'static str,
/// What goes after the name, in the usual notation: `<required>` and
/// `[optional]`. Empty for a command that takes nothing.
pub usage: &'static str,
pub description: &'static str,
/// Which services understand it. Every one of these is implemented by the
/// backend named here; a command listed for a service that cannot run it
/// would be worse than no menu at all.
pub services: &'static [&'static str],
}
const IRC: &[&str] = &["irc"];
const IRC_AND_MATRIX: &[&str] = &["irc", "matrix"];
/// Ordered roughly by how often each is wanted, since a fuzzy match that ties
/// keeps this order.
pub const BUILTINS: &[Builtin] = &[
Builtin { name: "me", usage: "<action>", description: "Say something as an action", services: IRC_AND_MATRIX },
Builtin { name: "join", usage: "<#channel>", description: "Join a channel", services: IRC },
Builtin { name: "part", usage: "", description: "Leave this channel", services: IRC },
Builtin { name: "msg", usage: "<nick> <message>", description: "Send somebody a private message", services: IRC },
Builtin { name: "nick", usage: "<newnick>", description: "Change your nick", services: IRC },
Builtin { name: "topic", usage: "<text>", description: "Set the channel topic", services: IRC },
Builtin { name: "away", usage: "[reason]", description: "Mark yourself away", services: IRC },
Builtin { name: "back", usage: "", description: "Stop being away", services: IRC },
Builtin { name: "whois", usage: "<nick>", description: "Look somebody up", services: IRC },
Builtin { name: "whowas", usage: "<nick>", description: "Look up somebody who has left", services: IRC },
Builtin { name: "notice", usage: "<target> <message>", description: "Send a notice", services: IRC },
Builtin { name: "ctcp", usage: "<nick> <request>", description: "Send a CTCP request", services: IRC },
Builtin { name: "invite", usage: "<nick>", description: "Invite somebody to this channel", services: IRC },
Builtin { name: "list", usage: "[pattern]", description: "List the network's channels", services: IRC },
Builtin { name: "kick", usage: "<nick> [reason]", description: "Remove somebody from the channel", services: IRC },
Builtin { name: "ban", usage: "<nick>", description: "Ban somebody from the channel", services: IRC },
Builtin { name: "unban", usage: "<mask>", description: "Lift a ban", services: IRC },
Builtin { name: "op", usage: "<nick>", description: "Give somebody operator status", services: IRC },
Builtin { name: "deop", usage: "<nick>", description: "Take operator status away", services: IRC },
Builtin { name: "voice", usage: "<nick>", description: "Give somebody voice", services: IRC },
Builtin { name: "devoice", usage: "<nick>", description: "Take voice away", services: IRC },
Builtin { name: "mode", usage: "[target] <modes>", description: "Set or read channel modes", services: IRC },
];
/// How well a typed fragment matches a command name, or nothing if it does
/// not match at all.
///
/// A subsequence rather than a prefix: people type the letters they remember,
/// and "dv" should find "devoice" the way it does in every editor. Higher is
/// better, and the three things that make a match better are all things
/// somebody typing has in mind - the letters being together, being at the
/// start, and the name being short enough that there is not much else it
/// could have been.
pub fn score(query: &str, name: &str) -> Option<i32> {
let query = query.trim().to_lowercase();
if query.is_empty() {
return Some(0);
}
let name_lower = name.to_lowercase();
let mut points = 0;
let mut at = 0usize;
let name_bytes: Vec<char> = name_lower.chars().collect();
let mut last_hit: Option<usize> = None;
for wanted in query.chars() {
let found = name_bytes.iter().skip(at).position(|c| *c == wanted)? + at;
// Letters that follow one another read as the word being typed out.
if last_hit == Some(found.wrapping_sub(1)) {
points += 5;
}
if found == 0 {
points += 10;
}
last_hit = Some(found);
at = found + 1;
}
// A whole-word match beats a scattered one of the same length, and a
// shorter name beats a longer one that merely contains the letters.
if name_lower.starts_with(&query) {
points += 20;
}
points += 10i32.saturating_sub(name_bytes.len() as i32);
Some(points)
}
/// The built-in commands a conversation on this service understands, best
/// match first.
pub fn matching(service: &str, query: &str) -> Vec<&'static Builtin> {
let mut hits: Vec<(i32, &'static Builtin)> = BUILTINS
.iter()
.filter(|c| c.services.contains(&service))
.filter_map(|c| score(query, c.name).map(|s| (s, c)))
.collect();
// Stable, so an exact tie keeps the order the table is written in - which
// is roughly how often each is wanted.
hits.sort_by(|a, b| b.0.cmp(&a.0));
hits.into_iter().map(|(_, c)| c).collect()
}
#[cfg(test)]
mod tests {
use super::{matching, score};
#[test]
fn a_prefix_finds_the_command() {
let hits = matching("irc", "ki");
assert_eq!(hits.first().map(|c| c.name), Some("kick"));
}
/// The thing a prefix search cannot do, and the reason this is a fuzzy
/// one: the letters somebody remembers are not always the first ones.
#[test]
fn scattered_letters_still_find_it() {
let hits = matching("irc", "dv");
assert!(hits.iter().any(|c| c.name == "devoice"), "got {:?}", hits.iter().map(|c| c.name).collect::<Vec<_>>());
}
#[test]
fn a_better_match_comes_first() {
// "ban" is the word; "unban" merely contains it.
let hits = matching("irc", "ban");
assert_eq!(hits[0].name, "ban");
assert!(hits.iter().any(|c| c.name == "unban"));
}
#[test]
fn nothing_typed_offers_everything_this_service_has() {
let irc = matching("irc", "");
assert!(irc.len() > 10);
// And a service with no commands of its own offers none rather than
// offering somebody else's.
assert!(matching("kick", "").is_empty());
assert!(matching("discord", "").is_empty());
}
#[test]
fn matrix_gets_the_ones_it_actually_implements() {
let names: Vec<&str> = matching("matrix", "").iter().map(|c| c.name).collect();
assert_eq!(names, vec!["me"]);
}
#[test]
fn letters_that_are_not_in_it_do_not_match() {
assert!(score("zzz", "kick").is_none());
assert!(score("kx", "kick").is_none());
}
}
+1
View File
@@ -1,5 +1,6 @@
mod accounts;
mod backend;
mod commands;
mod events;
mod highlights;
mod ipc;
+63 -1
View File
@@ -1197,7 +1197,69 @@ pub async fn dispatch(
}
// What a room has told everyone to read first.
// The slash commands this conversation offers, and running one.
// Every command that can be typed here, whoever implements it.
//
// One list rather than one per source: somebody typing a slash wants
// to know what happens next, and whether the answer is this daemon's
// own parser or a bot a server installed is not a distinction they
// are asking about. The menu says which anyway, on the right, the way
// Discord's own does.
//
// Built-ins are matched here rather than in the client for the reason
// the table is here at all: the commands belong to the daemon, and a
// second matcher in a window would be a second thing to keep in step.
"listCommands" => {
let Some(buffer_id) = p_str_opt(params, "bufferId") else {
return (None, Some("listCommands requires \"bufferId\"".to_string()));
};
let Some(buffer) = state.runtime.get_buffer(buffer_id) else {
return (None, Some("no such conversation".to_string()));
};
let query = p_str(params, "query", "");
let service = crate::model::service_of(&buffer.account_id);
let mut out: Vec<serde_json::Value> = crate::commands::matching(service, query)
.into_iter()
.map(|c| {
serde_json::json!({
"name": c.name,
"usage": c.usage,
"description": c.description,
"source": "Built-in",
"kind": "builtin",
})
})
.collect();
// And whatever this channel's bots offer, which only Discord has.
// Its own search does the matching, so a query no built-in
// answers can still be answered here.
if service == "discord" {
match backend::discord::list_commands(state, &buffer.account_id, buffer_id, query).await {
Ok(answer) => {
if let Some(rows) = answer["commands"].as_array() {
for row in rows {
out.push(serde_json::json!({
"name": row["name"],
"usage": backend::discord::command_usage(&row["command"]),
"description": row["description"],
"source": row["application"].as_str().unwrap_or("Application"),
"kind": "application",
"command": row["command"],
}));
}
}
}
// Not an error: a channel whose bots cannot be asked
// about still has the built-ins, and a menu that refused
// to open because one half of it failed would be worse
// than a menu missing that half.
Err(e) => tracing::debug!("discord: listing commands: {e:#}"),
}
}
(Some(serde_json::json!({ "commands": out })), None)
}
// The slash commands a channel's own bots offer, on their own.
//
// Listed per channel because that is the question with the right
// answer: a bot installed across a guild can still be unusable in the