Files
boxmaker/docs/plans/M4b/08-gatewayd-mention-commands.md
T
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

171 lines
6.9 KiB
Markdown

# 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),`:
```rust
/// `!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:
```rust
/// 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:
```rust
/// 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:
```rust
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:
```rust
// 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.