gatewayd: strip a leading mention of this bot; recognise approval commands

This commit is contained in:
2026-09-24 04:33:03 -07:00
parent b45ca14c14
commit 365e50081d
6 changed files with 267 additions and 28 deletions
+3 -1
View File
@@ -7,7 +7,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use crate::deliver::{LOOP_DOWN, deliver};
use crate::mm::{Post, typing};
use crate::serve::{Gateway, Stop};
use crate::sessions::{BUSY, Batch, Ignored, Pushed, Route};
use crate::sessions::{BUSY, Batch, Ignored, M4B_COMMAND, Pushed, Route};
use crate::state::InFlight;
use crate::ws::WsError;
use crate::ws::conn::Ws;
@@ -81,6 +81,8 @@ impl Gateway {
}
Route::Ignore(_) => {}
Route::Reply { thread, text } => self.post(&thread.channel, &thread.root, &text),
// Until the approvals module is wired in (task 15), M4a's notice.
Route::Approval { thread, .. } => self.post(&thread.channel, &thread.root, M4B_COMMAND),
Route::Queue(message) => {
if message.joins_thread {
self.state.join_thread(&message.thread.root)?;
+90 -9
View File
@@ -51,6 +51,81 @@ pub enum Route {
text: String,
},
Queue(Message),
/// `!approve` or `!deny`, for the approvals module to judge.
Approval {
thread: Thread,
command: ApprovalCommand,
/// Who wrote it, which post it is, and when (Mattermost's `create_at`).
user: String,
post: String,
at: i64,
},
}
/// An approval command as typed: exactly `!approve <id>`, `!deny <id>` or `!deny <id> <reason>`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApprovalCommand {
Approve(u64),
Deny(u64, Option<String>),
/// `!approve` or `!deny` in any other form.
Malformed,
}
/// The longest reason a `!deny` may give, in characters.
pub const MAX_REASON: usize = proto::MAX_REASON;
/// `!approve …` or `!deny …` as an approval command; `None` for any other text.
pub fn approval_command(text: &str) -> Option<ApprovalCommand> {
let after = text.strip_prefix('!')?;
let (word, rest) = match after.split_once(' ') {
Some((word, rest)) => (word, Some(rest)),
None => (after, None),
};
if !matches!(word, "approve" | "deny") {
return None;
}
let id = |s: &str| -> Option<u64> {
if s.is_empty() || s.len() > 20 || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse().ok()
};
let parsed = match (word, rest) {
("approve", Some(rest)) => id(rest).map(ApprovalCommand::Approve),
("deny", Some(rest)) => match rest.split_once(' ') {
None => id(rest).map(|n| ApprovalCommand::Deny(n, None)),
Some((n, reason)) => {
let fits = !reason.is_empty() && reason.chars().count() <= MAX_REASON;
id(n)
.filter(|_| fits)
.map(|n| ApprovalCommand::Deny(n, Some(reason.to_string())))
}
},
_ => None,
};
Some(parsed.unwrap_or(ApprovalCommand::Malformed))
}
/// The text after a leading mention of this bot (`me`, lower-case), and the spaces after it. The
/// mention is matched as `named` matches names; nothing else is removed. A message that would be
/// left empty is kept as it was.
pub fn strip_mention<'a>(message: &'a str, me: &str) -> &'a str {
let Some(after) = message.strip_prefix('@') else {
return message;
};
let len = after
.find(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')))
.unwrap_or(after.len());
let raw = after.get(..len).unwrap_or_default();
let name = raw.trim_end_matches('.');
if name.is_empty() || !name.eq_ignore_ascii_case(me) {
return message;
}
let rest = after
.get(name.len()..)
.unwrap_or_default()
.trim_start_matches(' ');
if rest.is_empty() { message } else { rest }
}
pub struct Router {
@@ -126,8 +201,9 @@ impl Router {
// Ignore(System); user not allowed -> Ignore(NotAllowed); then the channel: "D" is always
// ours; "O", "P" or "G" is ours when its id is allowed and `for_us`; anything else ->
// Ignore(NotForUs). The thread root is root_id, or the post id when root_id is empty. Then
// commands: "!!..." drops one "!" and goes on as a message; "!" then a first word approve
// or deny -> Reply M4B_COMMAND; any other "!" -> Reply UNKNOWN_COMMAND. Then Queue: session
// commands: Then a leading mention of this bot is stripped (M4b spec, section 7), and:
// "!!..." drops one "!" and goes on as a message; `approval_command` -> Approval; any
// other "!" -> Reply UNKNOWN_COMMAND. Then Queue: session
// "mm-<root>" (if SessionId::new fails, Ignore(NotForUs)), resume = root_id not empty,
// joins_thread = not "D".
if post.user_id == self.me_id {
@@ -162,7 +238,7 @@ impl Router {
root: root.clone(),
};
let message = &post.message;
let message = strip_mention(&post.message, &self.me_name);
if let Some(rest) = message.strip_prefix("!!") {
return Route::Queue(Message {
session,
@@ -172,14 +248,19 @@ impl Router {
joins_thread: channel_type != "D",
});
}
if let Some(after) = message.strip_prefix('!') {
let answer = match after.split_whitespace().next() {
Some("approve") | Some("deny") => M4B_COMMAND,
_ => UNKNOWN_COMMAND,
if let Some(command) = approval_command(message) {
return Route::Approval {
thread,
command,
user: post.user_id.clone(),
post: post.id.clone(),
at: post.create_at,
};
}
if message.starts_with('!') {
return Route::Reply {
thread,
text: answer.to_string(),
text: UNKNOWN_COMMAND.to_string(),
};
}
@@ -187,7 +268,7 @@ impl Router {
session,
thread,
resume: !post.root_id.is_empty(),
text: message.clone(),
text: message.to_string(),
joins_thread: channel_type != "D",
})
}
+1 -4
View File
@@ -176,10 +176,7 @@ fn channels_are_answered_only_when_named_or_in_our_thread() {
&post(&p1, KYLE, SHARED, "", "@boxmaker-straylight start", 5),
"O",
);
assert_eq!(
turns.recv_timeout(WAIT).unwrap().content,
"@boxmaker-straylight start"
);
assert_eq!(turns.recv_timeout(WAIT).unwrap().content, "start");
mm.wait_posts(1, WAIT);
ws.posted(
&post(&id('p', 2), KYLE, SHARED, &p1, "@hermes your turn", 6),
+30 -14
View File
@@ -3,7 +3,7 @@
use gatewayd::mm::Post;
use gatewayd::sessions::{
BUSY, Batch, Ignored, M4B_COMMAND, Message, Pushed, Queues, Route, Router, Thread,
ApprovalCommand, BUSY, Batch, Ignored, Message, Pushed, Queues, Route, Router, Thread,
UNKNOWN_COMMAND, named,
};
use proto::SessionId;
@@ -185,7 +185,7 @@ fn sessions_and_threads() {
assert!(reply.resume);
let channel = queued(r.route(&post(SHARED, "", "@boxmaker-straylight hi"), "O", &unknown));
assert!(channel.joins_thread && !channel.resume);
assert_eq!(channel.text, "@boxmaker-straylight hi");
assert_eq!(channel.text, "hi", "the leading mention is stripped (M4b)");
}
#[test]
@@ -195,21 +195,37 @@ fn commands() {
channel: DM.to_string(),
root: ROOT.to_string(),
};
let reply = |text: &str| Route::Reply {
thread: thread.clone(),
text: text.to_string(),
};
for (message, answer) in [
("!approve 42", M4B_COMMAND),
("!deny 42 not now", M4B_COMMAND),
("!deny", M4B_COMMAND),
("!approved", UNKNOWN_COMMAND),
("!help", UNKNOWN_COMMAND),
("!", UNKNOWN_COMMAND),
for (message, command) in [
("!approve 42", ApprovalCommand::Approve(42)),
(
"!deny 42 not now",
ApprovalCommand::Deny(42, Some("not now".to_string())),
),
("!deny", ApprovalCommand::Malformed),
(
"@boxmaker-straylight !approve 7",
ApprovalCommand::Approve(7),
),
] {
assert_eq!(
r.route(&post(DM, ROOT, message), "D", &unknown),
reply(answer),
Route::Approval {
thread: thread.clone(),
command,
user: KYLE.to_string(),
post: POST.to_string(),
at: 5,
},
"{message}"
);
}
for message in ["!approved", "!help", "!"] {
assert_eq!(
r.route(&post(DM, ROOT, message), "D", &unknown),
Route::Reply {
thread: thread.clone(),
text: UNKNOWN_COMMAND.to_string(),
},
"{message}"
);
}
+142
View File
@@ -0,0 +1,142 @@
//! A leading mention of this bot is stripped before anything reads a message, and approval
//! commands are recognised exactly (M4b spec, section 7). Do not edit.
use gatewayd::mm::Post;
use gatewayd::sessions::{
ApprovalCommand, MAX_REASON, Route, Router, approval_command, strip_mention,
};
const ME: &str = "boxmaker0";
const BOT: &str = "b0000000000000000000000000";
const KYLE: &str = "k0000000000000000000000000";
const SHARED: &str = "c0000000000000000000000000";
const ROOT: &str = "r0000000000000000000000000";
#[test]
fn a_leading_mention_is_stripped() {
assert_eq!(
strip_mention("@boxmaker0 summarise the log", ME),
"summarise the log"
);
assert_eq!(strip_mention("@BoxMaker0 !approve 42", ME), "!approve 42");
assert_eq!(
strip_mention("@boxmaker0 @hermes compare notes", ME),
"@hermes compare notes"
);
assert_eq!(strip_mention("@boxmaker0, look", ME), ", look");
assert_eq!(
strip_mention("@boxmaker0\nsecond line", ME),
"\nsecond line",
"only spaces go with it"
);
}
#[test]
fn anything_else_is_kept() {
for message in [
"@boxmaker0",
"@boxmaker0 ",
"@boxmaker0x hello",
"@hermes @boxmaker0 hello",
"hello @boxmaker0",
" @boxmaker0 hello",
"@ boxmaker0 hello",
"@@boxmaker0 hello",
"",
] {
assert_eq!(strip_mention(message, ME), message, "{message:?}");
}
assert_eq!(
strip_mention("@boxmaker0. hi", ME),
". hi",
"the trailing dot is not part of the name"
);
}
#[test]
fn approval_commands_exactly() {
use ApprovalCommand::{Approve, Deny, Malformed};
let cases: &[(&str, Option<ApprovalCommand>)] = &[
("!approve 42", Some(Approve(42))),
("!approve 0", Some(Approve(0))),
("!approve 18446744073709551615", Some(Approve(u64::MAX))),
("!deny 42", Some(Deny(42, None))),
(
"!deny 42 not on a Friday",
Some(Deny(42, Some("not on a Friday".to_string()))),
),
(
"!deny 42 two spaces",
Some(Deny(42, Some(" two spaces".to_string()))),
),
("!approve", Some(Malformed)),
("!approve 42", Some(Malformed)),
("!approve 42 ", Some(Malformed)),
("!approve 42 now", Some(Malformed)),
("!approve +42", Some(Malformed)),
("!approve 4x2", Some(Malformed)),
("!approve 18446744073709551616", Some(Malformed)),
("!deny", Some(Malformed)),
("!deny 42 ", Some(Malformed)),
("!deny x reason", Some(Malformed)),
("!approved 42", None),
("!denied 42", None),
("!help", None),
("approve 42", None),
("!Approve 42", None),
];
for (text, want) in cases {
assert_eq!(&approval_command(text), want, "{text:?}");
}
let long = format!("!deny 42 {}", "é".repeat(MAX_REASON));
assert!(
matches!(approval_command(&long), Some(Deny(42, Some(_)))),
"characters, not bytes"
);
let over = format!("!deny 42 {}", "x".repeat(MAX_REASON + 1));
assert_eq!(approval_command(&over), Some(Malformed));
}
fn post(message: &str, root: &str) -> Post {
Post {
id: "p0000000000000000000000001".to_string(),
user_id: KYLE.to_string(),
channel_id: SHARED.to_string(),
root_id: root.to_string(),
message: message.to_string(),
create_at: 9,
delete_at: 0,
kind: String::new(),
}
}
#[test]
fn in_a_shared_thread_the_mention_picks_the_bot() {
let me = Router::new(BOT, ME, &[KYLE.to_string()], &[SHARED.to_string()]);
let ours = |root: &str| root == ROOT;
let addressed = me.route(&post("@boxmaker0 !approve 42", ROOT), "O", &ours);
assert!(
matches!(
addressed,
Route::Approval {
command: ApprovalCommand::Approve(42),
..
}
),
"{addressed:?}"
);
let other = me.route(&post("@boxmaker1 !approve 42", ROOT), "O", &ours);
assert!(
matches!(other, Route::Ignore(_)),
"addressed to another bot: {other:?}"
);
let bare = me.route(&post("!approve 42", ROOT), "O", &ours);
assert!(
matches!(bare, Route::Approval { .. }),
"a bare command in our thread is ours too: {bare:?}"
);
match me.route(&post("@boxmaker0 hello there", ""), "O", &ours) {
Route::Queue(m) => assert_eq!(m.text, "hello there"),
other => panic!("{other:?}"),
}
}