diff --git a/crates/gatewayd/src/serve/handle.rs b/crates/gatewayd/src/serve/handle.rs index 30f1e2a..2d00adc 100644 --- a/crates/gatewayd/src/serve/handle.rs +++ b/crates/gatewayd/src/serve/handle.rs @@ -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)?; diff --git a/crates/gatewayd/src/sessions.rs b/crates/gatewayd/src/sessions.rs index d9b78c7..ad61878 100644 --- a/crates/gatewayd/src/sessions.rs +++ b/crates/gatewayd/src/sessions.rs @@ -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 `, `!deny ` or `!deny `. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ApprovalCommand { + Approve(u64), + Deny(u64, Option), + /// `!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 { + 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 { + 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-" (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", }) } diff --git a/crates/gatewayd/tests/serve.rs b/crates/gatewayd/tests/serve.rs index 6f29a84..e5e1b1c 100644 --- a/crates/gatewayd/tests/serve.rs +++ b/crates/gatewayd/tests/serve.rs @@ -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), diff --git a/crates/gatewayd/tests/sessions.rs b/crates/gatewayd/tests/sessions.rs index 3c48d77..6313c06 100644 --- a/crates/gatewayd/tests/sessions.rs +++ b/crates/gatewayd/tests/sessions.rs @@ -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}" ); } diff --git a/crates/gatewayd/tests/sessions_mention.rs b/crates/gatewayd/tests/sessions_mention.rs new file mode 100644 index 0000000..2a3de4a --- /dev/null +++ b/crates/gatewayd/tests/sessions_mention.rs @@ -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)] = &[ + ("!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:?}"), + } +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index c0cbae9..3cfb3aa 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| M4b/08-gatewayd-mention-commands | 2026-09-24 | done | 1 | pass | none | Copied `sessions_mention.rs` into `crates/gatewayd/tests/`, the new `sessions.rs` (commands now route to `Approval`, the mention stripped) and `serve.rs` (from `serve.rs-task08`, the turn's text is `start`). In `sessions.rs`: added `Route::Approval { thread, command, user, post, at }` after `Queue(Message)`; added the given `ApprovalCommand` enum, `MAX_REASON = proto::MAX_REASON`, and `approval_command` (returns `None` for text that is not a `!` command, else `Approve`/`Deny`/`Malformed`); wrote `strip_mention` (the five steps: strip a leading `@`, take the longest name run, trim a trailing dot, match `me` case-insensitively, return the message when the remainder is empty); in `route` bound `message` from `strip_mention(&post.message, &self.me_name)`, replaced the `!` command block with `approval_command` → Approval then any other `!` → Reply UNKNOWN_COMMAND, and changed the final Queue's `text: message.clone()` to `message.to_string()` since `message` is now `&str`; updated the step comment. In `handle.rs` `handle_post` added the `Route::Approval` arm posting M4B_COMMAND (approvals module wired in task 15) and `M4B_COMMAND` to the `use`. All three suites pass (serve 6, sessions 8, sessions_mention 4); `make gate` prints `gate: ok` on the first run. | ? | | M4b/07-gatewayd-config-approvals | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/tests/config_approvals.rs` into `crates/gatewayd/tests/`. In `config.rs`: added `#[serde(default)] pub approvals: ApprovalsConfig,` after `pub limits: Limits,` in `Config`, the `ApprovalsConfig { users: Vec, socket: PathBuf }` struct (`#[serde(deny_unknown_fields, default)]`, with the doc comments) after `struct Config`, the `[approvals] users` check in `problem` right before the `[limits] queue` check (each id must be in `allow.users`), and the three methods before `state_path`: `approvers` (`&self.approvals.users`, or `&self.allow.users` when empty), `first_approver` (`.first().map(String::as_str)`), and `gateway_socket` (`self.approvals.socket`, or `/run/gateway/gateway.sock` when `as_os_str().is_empty()`). The `ApprovalsConfig` derives `Default` so the empty `socket` deserialises from an absent field; `PathBuf`'s default is empty, matching the runbook's "empty" rule. `cargo fmt --all` then `cargo test -p gatewayd --test config_approvals --test config` green (4 and 7 passed); `make gate` prints `gate: ok` on the first run. | ? | | M4b/06-brokerd-courier | 2026-09-24 | done | 1 | pass | none | Filled the four `todo!()`s in the copied `courier.rs`: `finished` (`stop.load(SeqCst) || !broker.table().contains(id)`); `pause` (a `while Instant::now() < until && !finished` loop sleeping `timing.tick.min(until.saturating_duration_since(now))`); `frames` (`try_clone` the stream, spawn a reader thread that hands whole frames over an mpsc channel so the courier can `recv_timeout` without losing half a frame, `Ok(rx)`); `answer` (`take` the entry → `Gone {}` if gone, build the `Answer` from `reply.approved`, call `admin::answer`, map the returned `DecisionRecord` through the five-arm match to `Runs`/`Denied`/`Refused`/`Denied`/`Refused`). The four `broker.rs` changes: `RecvTimeoutError` import widened to include `Receiver`, `use crate::courier;` after the `config` use, `let shown = info.clone();` before the insert, step 4 replaced by `courier::alongside(broker, &shown, || wait_for_verdict(...))` matching `Waited`, and the old step 4 moved to `wait_for_verdict` (with the `Waited` enum) before `pub fn alive`. `cargo build -p brokerd` clean; courier test went 1 pass/9 fail then 10 passed three times running; full brokerd suite green; `cargo fmt --all` then `make gate` prints `gate: ok` on the first run. | ? | | M4b/05-brokerd-config-gateway | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/brokerd/tests/config_gateway.rs` and the new `support/rig.rs` (whose `Config` gained `gateway: None`) into `crates/brokerd/tests/`. In `config.rs`: added the `Gateway { socket: PathBuf }` struct (`#[serde(deny_unknown_fields, default)]`) before `default_podman`, the `gateway: Option` field (with `#[serde(default)]`) after `runner` in `Config`, and the `gateway_socket()` method before `audit_dir` — `None` when the section is absent, the default `/run/gateway/gateway.sock` when the socket is empty, else the path as given. `cargo fmt --all` then `cargo test -p brokerd` green with `config_gateway` 4 passed; `make gate` prints `gate: ok` on the first run. | ? |