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>
6.9 KiB
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):
- 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 42is a command, and@boxmaker0 summarise the logreaches the model assummarise the log. Where two Boxmakers share a thread, that is how the owner addresses one of them. - Approval commands.
!approve <id>,!deny <id>and!deny <id> <reason>(exactly) become a newRoute::Approval, carrying the command, who wrote it, which post, and when. Task 15 hands it to the approvals module; until thenserveanswers it with M4a's notice, so nothing else changes yet. Any other!…is still answered withUNKNOWN_COMMAND.
Files
- Copy:
crates/gatewayd/tests/sessions_mention.rs, and new versions of two given tests:crates/gatewayd/tests/sessions.rs(commands now route toApproval; the mention is stripped) andcrates/gatewayd/tests/serve.rs, fromserve.rs-task08(one line: the turn's text isstart, 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
let Some(after) = message.strip_prefix('@') else { return message; };- 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();andlet name = raw.trim_end_matches('.');(a trailing dot is not part of a name). nameempty, or notname.eq_ignore_ascii_case(me): returnmessage.let rest = after.get(name.len()..).unwrap_or_default().trim_start_matches(' ');(spaces only, not newlines).if rest.is_empty() { message } else { rest }.
4. In Router::route:
let message = &post.message;becomeslet message = strip_mention(&post.message, &self.me_name);- The block
if let Some(after) = message.strip_prefix('!') { … }(which answeredM4B_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(),becomestext: 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, thencp 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_mentionlast. Runcargo 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 gateprintsgate: ok.
Stop and report if
- A test wants the mention kept, or stripped somewhere other than at the very start.