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>
171 lines
6.9 KiB
Markdown
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.
|