Files
boxmaker/docs/plans/M4b/08-gatewayd-mention-commands.md
kyleandClaude Opus 5.5 2d415fc1be Plan M4b: approvals over Mattermost in 15 tasks
Skeletons for new files only; existing files change by exact edits, so no task overwrites an
earlier one's work. Each task's tests were run against a reference built in order from master,
with the gate at each step and mutations checked; each skeleton compiles and fails its tests, and
every todo!() comment was read beside its signature. The reference is kept off this machine.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 03:34:21 -07:00

6.9 KiB

M4b task 08: the leading mention, and approval commands

Branch: m4b (run git switch m4b; git status --short must be empty, otherwise stop) Commit subject: gatewayd: strip a leading mention of this bot; recognise approval commands

Goal

Two changes to routing (M4b spec, section 7):

  1. The leading mention. After M4a has decided a post is for this Boxmaker, a leading @<this bot> is stripped before anything reads the message: @boxmaker0 !approve 42 is a command, and @boxmaker0 summarise the log reaches the model as summarise the log. Where two Boxmakers share a thread, that is how the owner addresses one of them.
  2. Approval commands. !approve <id>, !deny <id> and !deny <id> <reason> (exactly) become a new Route::Approval, carrying the command, who wrote it, which post, and when. Task 15 hands it to the approvals module; until then serve answers it with M4a's notice, so nothing else changes yet. Any other !… is still answered with UNKNOWN_COMMAND.

Files

  • Copy: crates/gatewayd/tests/sessions_mention.rs, and new versions of two given tests: crates/gatewayd/tests/sessions.rs (commands now route to Approval; the mention is stripped) and crates/gatewayd/tests/serve.rs, from serve.rs-task08 (one line: the turn's text is start, no longer @boxmaker-straylight start)
  • Modify: crates/gatewayd/src/sessions.rs, crates/gatewayd/src/serve/handle.rs, docs/implementer-log.md

The changes, exactly

In crates/gatewayd/src/sessions.rs:

1. In enum Route, after 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,
    },

2. After enum Route, the command type and approval_command, as given:

/// 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))
}

3. Write strip_mention yourself, next to them:

/// 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
  1. let Some(after) = message.strip_prefix('@') else { return message; };
  2. The name's length: after.find(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))).unwrap_or(after.len()); let raw = after.get(..len).unwrap_or_default(); and let name = raw.trim_end_matches('.'); (a trailing dot is not part of a name).
  3. name empty, or not name.eq_ignore_ascii_case(me): return message.
  4. let rest = after.get(name.len()..).unwrap_or_default().trim_start_matches(' '); (spaces only, not newlines).
  5. if rest.is_empty() { message } else { rest }.

4. In Router::route:

  • let message = &post.message; becomes let message = strip_mention(&post.message, &self.me_name);
  • The block if let Some(after) = message.strip_prefix('!') { … } (which answered M4B_COMMAND) becomes:
        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: UNKNOWN_COMMAND.to_string(),
            };
        }
  • In the final Route::Queue, text: message.clone(), becomes text: message.to_string(),.
  • In route's step comment, the part about commands now reads: "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."

In crates/gatewayd/src/serve/handle.rs, handle_post's match route gains, after the Route::Reply arm:

            // Until the approvals module is wired in (task 15), M4a's notice.
            Route::Approval { thread, .. } => {
                self.post(&thread.channel, &thread.root, M4B_COMMAND)
            }

and its use crate::sessions::{…} gains M4B_COMMAND.

Steps

  • 1. Copy. git switch m4b, then cp docs/plans/M4b/files/crates/gatewayd/tests/sessions_mention.rs docs/plans/M4b/files/crates/gatewayd/tests/sessions.rs crates/gatewayd/tests/ && cp docs/plans/M4b/files/crates/gatewayd/tests/serve.rs-task08 crates/gatewayd/tests/serve.rs
  • 2. See it fail. cargo test -p gatewayd --test sessions_mention. Expected: it does not compile.
  • 3. Make the changes, strip_mention last. Run cargo fmt --all.
  • 4. See it pass. cargo test -p gatewayd --test sessions_mention --test sessions --test serve. Expected: 4, 8 and 6 passed.
  • 5. Run the gate. make gate. Expected last line: gate: ok.
  • 6. Log and commit. git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit

Done when

  • The three suites pass; make gate prints gate: ok.

Stop and report if

  • A test wants the mention kept, or stripped somewhere other than at the very start.