diff --git a/crates/gatewayd/src/approvals/text.rs b/crates/gatewayd/src/approvals/text.rs index fb2bb07..7a1e79e 100644 --- a/crates/gatewayd/src/approvals/text.rs +++ b/crates/gatewayd/src/approvals/text.rs @@ -173,3 +173,52 @@ pub fn ended_line(ended: &Ended) -> String { pub fn with_ending(last: &str, ended: &Ended) -> String { format!("{last}\n\n{}", ended_line(ended)) } + +/// The most characters of an asker's message shown to the approver: it is context, not what is +/// approved, so it may be cut. +pub const PROMPT_SHOWN: usize = 2_000; + +/// Who asked for an approval that is shown elsewhere (M4b spec, section 6). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Asked { + /// `@username`, or the user id when the name could not be found. + pub by: String, + /// The channel's type: `D` a direct message, `G` a group message. + pub channel_type: String, + /// The text of the turn that asked. + pub prompt: String, +} + +/// The first post of a redirected approval: who asked, where, and their message. +pub fn asked_post(id: u64, asked: &Asked) -> String { + let place = if asked.channel_type == "G" { + "a group message" + } else { + "a direct message" + }; + let escaped = proto::escape::escape_model_text(&asked.prompt); + let total = escaped.chars().count(); + let shown: String = escaped.chars().take(PROMPT_SHOWN).collect(); + let mut post = format!( + "**Approval {id}** was asked by {} in {place} with the bot. Their message:\n{}", + asked.by, + fence(&shown) + ); + if total > PROMPT_SHOWN { + post.push_str(&format!("\n(… {} more characters)", total - PROMPT_SHOWN)); + } + post +} + +/// The first post of an approval whose session `gatewayd` does not follow. +pub fn unfollowed_post(id: u64, session: &str) -> String { + format!( + "**Approval {id}** was asked in session {}, which gatewayd does not follow now (a restart, or a session from outside Mattermost).", + code(session) + ) +} + +/// The line left in the asking thread when its approval goes to an approver. +pub fn sent_to_approver(id: u64) -> String { + format!("waiting for approval {id}: it was sent to an approver") +} diff --git a/crates/gatewayd/tests/approvals_asked.rs b/crates/gatewayd/tests/approvals_asked.rs new file mode 100644 index 0000000..8bd3a55 --- /dev/null +++ b/crates/gatewayd/tests/approvals_asked.rs @@ -0,0 +1,86 @@ +//! The posts that tell an approver who asked for a redirected approval and what, and the line left +//! in the asking thread (M4b spec, section 6, after the review). Do not edit. + +use gatewayd::approvals::text::{ + Asked, PROMPT_SHOWN, asked_post, sent_to_approver, unfollowed_post, +}; + +/// The escape for one code point, built here so this file holds no such character itself. +fn esc(code: u32) -> String { + format!("{}u{code:04x}", char::from(92)) +} + +fn asked(prompt: &str, channel_type: &str) -> Asked { + Asked { + by: "@alex".to_string(), + channel_type: channel_type.to_string(), + prompt: prompt.to_string(), + } +} + +#[test] +fn who_where_and_what_they_asked() { + assert_eq!( + asked_post( + 3, + &asked("Read the file /srv/notes/q3.txt and summarise it.", "D") + ), + "**Approval 3** was asked by @alex in a direct message with the bot. Their message:\n```\nRead the file /srv/notes/q3.txt and summarise it.\n```" + ); + let group = asked_post(4, &asked("first\n\nsecond", "G")); + assert_eq!( + group, + "**Approval 4** was asked by @alex in a group message with the bot. Their message:\n```\nfirst\n\nsecond\n```", + "a burst keeps its lines" + ); +} + +#[test] +fn the_message_is_escaped_and_fenced() { + let hostile = format!( + "see ``` and @channel{}{}", + char::from_u32(0x202e).unwrap(), + char::from(7) + ); + let post = asked_post(5, &asked(&hostile, "D")); + assert!( + post.contains(&format!( + "\n````\nsee ``` and @channel{}{}\n````", + esc(0x202e), + esc(7) + )), + "{post}" + ); + assert!(!post.contains(char::from_u32(0x202e).unwrap()) && !post.contains(char::from(7))); +} + +#[test] +fn a_long_message_is_cut_and_says_how_much() { + let long = "é".repeat(PROMPT_SHOWN + 25); + let post = asked_post(6, &asked(&long, "D")); + assert!( + post.ends_with(&format!( + "\n```\n{}\n```\n(… 25 more characters)", + "é".repeat(PROMPT_SHOWN) + )), + "{}", + &post[post.len() - 80..] + ); + let exact = asked_post(6, &asked(&"x".repeat(PROMPT_SHOWN), "D")); + assert!( + !exact.contains("more characters"), + "nothing cut, nothing said" + ); +} + +#[test] +fn an_unfollowed_session_and_the_note() { + assert_eq!( + unfollowed_post(9, "chat-1789700000-1"), + "**Approval 9** was asked in session `chat-1789700000-1`, which gatewayd does not follow now (a restart, or a session from outside Mattermost)." + ); + assert_eq!( + sent_to_approver(3), + "waiting for approval 3: it was sent to an approver" + ); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 7595acb..40aa319 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/17-gatewayd-asked-texts | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/tests/approvals_asked.rs` into `crates/gatewayd/tests/`. In `approvals/text.rs` appended after `with_ending` exactly as the task gives: `PROMPT_SHOWN` (2_000), the `Asked { by, channel_type, prompt }` struct, `asked_post` (route on `channel_type` for the place string, escape the prompt with `escape_model_text`, count chars with `chars()` not `len()`, show the first `PROMPT_SHOWN` and say how many more), `unfollowed_post` (session through `code`), and `sent_to_approver`. `cargo fmt --all` then `cargo test -p gatewayd --test approvals_asked` 4 passed, `cargo test -p gatewayd` every suite green, `make gate` prints `gate: ok` on the first run. | ? | | M4b/16-brokerd-refusing-message | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/brokerd/tests/runner.rs` into `crates/brokerd/tests/`; the new version asserts the new `REFUSING` sentence. In `crates/brokerd/src/runner.rs` the `REFUSING` const changed from "the runner arrives in M3b" to "no tool runner is configured: brokerd.toml has no [runner] section"; the doc comment above it unchanged. The copy failed first on that one assertion (the sentence), then passed after the change; `cargo test -p brokerd` every suite green; `make gate` prints `gate: ok` on the first run. `loopd/tests/broker_port.rs` left alone (old sentence is only test data there). | ? | | M4b/15-gatewayd-answers | 2026-09-24 | done | 1 | pass | none | Copied `serve_answers.rs` and `deliver_notice.rs` (new), and new versions of `deliver.rs`, `serve.rs` (`!approve 3` now "approval 3 is not pending here") and `support/gateway.rs` (`Tuning` gains `notice`) into `crates/gatewayd/tests/`. `approvals/mod.rs`: added `ever: Mutex>` (doc "Every approval ever posted here, answered or not: for the notice.", `HashSet` imported beside `HashMap`); `add` inserts `posted.approval` into `ever` before into `posted`, and added `was_posted`. `deliver.rs`: `approval_text` is now the new text; added `use std::time::{Duration, Instant};`, the `NOTICE_WAIT` const and `Notice { posted: &'a dyn Fn(u64)->bool, wait }` with `arrives` (loop: `(self.posted)(id)` -> true, past `Instant::now()+wait` taken once -> false, else sleep `50ms.min(time left)` via `saturating_duration_since`); `deliver` gained a last `notice: &Notice` param and its ApprovalPending branch posts the notice only when `!notice.arrives(*approval)`. `serve/mod.rs`: `Tuning.notice` (doc, `crate::deliver::NOTICE_WAIT` in Default); `Gateway.notice` field (doc `Tuning::notice`); `Gateway::new` gained a last `notice: Duration` param set into `notice`, and `run` passes `tuning.notice`. `serve/handle.rs`: `Route::Approval` arm now calls `self.registry.judge(&command,&user,&thread.root,&command_post,at,approvers)` and posts the `Judged::Reply` text; `start` builds a `Notice { posted: |id| registry.was_posted(id), wait: self.notice }` and passes it to `deliver`; `typing` takes `self.registry.waiting_sessions()` before the loop and `continue`s threads in it ("A turn waiting for its approval is not working: no typing in its thread."); imported `Judged` and `Notice`, dropped `M4B_COMMAND`. `sessions.rs`: deleted `M4B_COMMAND`. `serve/approvals.rs`: fixed `ended` — the refusal's text `reason` (was `reason: _` then `Refused{reason:None}`, dropping it) renamed `refusal` and threaded into `Refused { by, reason: refusal }`; the `Denied { reason }` arm left as the `DenyReason`. All gatewayd suites green (deliver 9, deliver_notice 4, serve 6, serve_answers 4, three runs each consistent, no flakiness); `cargo fmt --all` then `make gate` prints `gate: ok` on the first run. | ? | | M4b/14-gatewayd-serve-approvals | 2026-09-24 | done | 1 | pass | none | Copied the skeleton `approvals.rs`, `serve_approvals.rs`, the new `support/brokerd_client.rs`, and a new `support/fake_mm.rs` (which gains `edits`/`wait_edits`, `username` via `GET /api/v4/users/{user}`, and `refuse_posts` returning 400 on `POST /api/v4/posts`). In `serve/mod.rs`: added `mod approvals;` before `mod handle;` and the `HashMap`, `Registry` and `gateway::{self, ToLoop}` imports; the three `registry`/`gateway_rx`/`open` fields on `Gateway`; `Gateway::new` gained a `gateway_rx: Receiver` last parameter and set `registry`, `gateway_rx`, `open`; `run` now `listen`s `gateway.sock` and `serve`s it on its own thread before `Client::new`, returning `Stop::Start` on either failure and passing `gateway_rx` to `Gateway::new`; `connected` calls `self.mark_left_behind()?` after the `take_in_flight` loop; `event_loop` calls `self.gateway_requests()?` after `self.finished()?`. Filled the four `todo!()`s in `approvals.rs`: `place` (`turn_of` the session's in-flight turn, else the first approver's `direct_channel` as a new empty thread, each error string as given); `show` was already written; `ended` removes from the registry and state, returns when there is no `open`, maps `Ending::NoLonger`/`Answered` to the `Ended` (the `by` run through `username` as `@` or left the id, and `outcome` → `Runs`/`Denied`/`Refused{reason:None}`/`Gone`); `mark_left_behind` edits each left-behind approval's last post with the title and the `Restarted` line; `mark` patches the post and logs a failure. The one clippy warning was the unused `reason` of `Ending::Answered` (the real reason comes from `outcome`), ignored with `reason: _`. All five `serve_approvals` tests pass three times; the whole gatewayd suite is green; `cargo fmt --all` then `make gate` prints `gate: ok` on the first run. | ? |