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>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
//! Approvals in Mattermost (M4b spec, sections 6 and 7): the text of the posts, and the
|
||||
//! approvals this `gatewayd` has posted and the commands that answer them.
|
||||
|
||||
pub mod text;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
use proto::ApprovalReply;
|
||||
|
||||
use crate::sessions::ApprovalCommand;
|
||||
|
||||
pub const NOT_APPROVER: &str = "only an approver can answer approvals";
|
||||
pub const USAGE: &str = "use !approve <id> or !deny <id> <reason>";
|
||||
|
||||
/// One approval posted here and not yet answered.
|
||||
#[derive(Debug)]
|
||||
pub struct Posted {
|
||||
pub approval: u64,
|
||||
/// The session whose turn waits for it.
|
||||
pub session: String,
|
||||
/// The thread it is answered in.
|
||||
pub channel: String,
|
||||
pub root: String,
|
||||
/// The last post's `create_at`: only a command after it counts.
|
||||
pub shown_at: i64,
|
||||
/// False when the arguments were too long to show: only `bxctl` answers it.
|
||||
pub answerable: bool,
|
||||
/// Where an accepted answer goes: the thread of its `gateway.sock` connection.
|
||||
pub answer: Sender<ApprovalReply>,
|
||||
}
|
||||
|
||||
/// What a command came to.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Judged {
|
||||
/// The answer went to `brokerd`'s connection; the approval post will show the outcome.
|
||||
Sent,
|
||||
/// Refused: this line goes in the thread.
|
||||
Reply(String),
|
||||
}
|
||||
|
||||
/// The approvals posted here, by id.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Registry {
|
||||
posted: Mutex<HashMap<u64, Posted>>,
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
pub fn new() -> Registry {
|
||||
Registry::default()
|
||||
}
|
||||
|
||||
fn lock(&self) -> MutexGuard<'_, HashMap<u64, Posted>> {
|
||||
self.posted.lock().unwrap_or_else(|p| p.into_inner())
|
||||
}
|
||||
|
||||
pub fn add(&self, posted: Posted) {
|
||||
// `self.lock().insert(posted.approval, posted);`
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Take it out: it ended, or its connection is done with it.
|
||||
pub fn remove(&self, id: u64) -> Option<Posted> {
|
||||
// `self.lock().remove(&id)`
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub fn holds(&self, id: u64) -> bool {
|
||||
// `self.lock().contains_key(&id)`
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The sessions with an approval waiting here: their turns are waiting, not working.
|
||||
pub fn waiting_sessions(&self) -> Vec<String> {
|
||||
// The `session` of every entry: `self.lock().values().map(|p|
|
||||
// p.session.clone()).collect()`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Judge a command from `user`, posted as `post` at `at` in the thread `root`, in the order of
|
||||
/// the spec's section 7. An accepted answer is taken out and sent; nothing else changes.
|
||||
pub fn judge(
|
||||
&self,
|
||||
command: &ApprovalCommand,
|
||||
user: &str,
|
||||
root: &str,
|
||||
post: &str,
|
||||
at: i64,
|
||||
approvers: &[String],
|
||||
) -> Judged {
|
||||
// In this order; each refusal is `Judged::Reply(<text>)` and changes nothing:
|
||||
// 1. `user` not in `approvers`: NOT_APPROVER.
|
||||
// 2. The command: Approve(id) is (id, approved true, reason None); Deny(id, reason) is (id,
|
||||
// false, reason.clone()); Malformed: USAGE.
|
||||
// 3. `let mut posted = self.lock();` Not in `posted`: "approval {id} is not pending here".
|
||||
// 4. `!entry.answerable`: "approval {id} can only be answered with bxctl approvals".
|
||||
// 5. `entry.root != root`: "approval {id} is answered in its own thread".
|
||||
// 6. `at <= entry.shown_at`: "approval {id} was answered before it was shown in full".
|
||||
// 7. Take it out (`posted.remove(&id)`) and send `ApprovalReply { approval: id, approved,
|
||||
// by: user, post, reason }` on its `answer`: Ok -> `Judged::Sent`; Err (the connection
|
||||
// is gone) -> "approval {id} is not pending here".
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Approvals in Mattermost (M4b spec, sections 6 and 7): the text of the posts, and the
|
||||
//! approvals this `gatewayd` has posted and the commands that answer them.
|
||||
|
||||
pub mod text;
|
||||
@@ -0,0 +1,157 @@
|
||||
//! The text of an approval's posts, and of the line added when it ends (M4b spec, section 6).
|
||||
//! Nothing here is shortened: arguments too long to show whole are not shown at all.
|
||||
|
||||
use proto::escape::escape_json_text;
|
||||
use proto::{DenyReason, PendingApproval, Timestamp, sha256};
|
||||
|
||||
/// Arguments are split into parts of at most this many characters.
|
||||
pub const PART_CHARS: usize = 15_000;
|
||||
/// At most this many parts; more are answerable only in `bxctl`.
|
||||
pub const MAX_PARTS: usize = 4;
|
||||
/// The longest a part's post may be, leaving room for the line added when it ends.
|
||||
pub const MAX_PART_POST: usize = 15_500;
|
||||
|
||||
/// An approval's posts, in order. Only an answer after the last counts; when `answerable` is
|
||||
/// false, no command answers it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Shown {
|
||||
pub posts: Vec<String>,
|
||||
pub answerable: bool,
|
||||
}
|
||||
|
||||
/// How an approval ended, for the line added to its last post.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Ended {
|
||||
Runs { by: String },
|
||||
Denied { by: String, reason: DenyReason },
|
||||
Refused { by: String, reason: Option<String> },
|
||||
NoLonger,
|
||||
Restarted,
|
||||
}
|
||||
|
||||
/// `text` as inline code, with a backtick run longer than any inside it.
|
||||
pub fn code(text: &str) -> String {
|
||||
// `let ticks = "`".repeat(longest_run(text) + 1);`, and a pad of one space on both sides when
|
||||
// `text` starts or ends with a backtick (CommonMark needs it), else none:
|
||||
// `format!("{ticks}{pad}{text}{pad}{ticks}")`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// `text` as a fenced block, with a fence longer than any backtick run inside it (at least 3).
|
||||
pub fn fence(text: &str) -> String {
|
||||
// `let ticks = "`".repeat((longest_run(text) + 1).max(3));` then
|
||||
// `format!("{ticks}\n{text}\n{ticks}")`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn longest_run(text: &str) -> usize {
|
||||
// The longest run of consecutive backticks in `text`: walk `text.chars()`, counting up on a
|
||||
// backtick and back to 0 on anything else, keeping the most seen.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// `2026-09-24 14:47 UTC`.
|
||||
fn minute(at: Timestamp) -> String {
|
||||
// `let rfc = at.to_rfc3339();` ("2026-09-24T14:47:10.000Z"); then `format!("{} {} UTC",
|
||||
// rfc.get(..10).unwrap_or_default(), rfc.get(11..16).unwrap_or_default())`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// A serde name (`private`, `no_grant`), or the debug form if there is none.
|
||||
fn name<T: serde::Serialize + std::fmt::Debug>(value: &T) -> String {
|
||||
// `match serde_json::to_value(value)`: `Ok(serde_json::Value::String(s))` -> s; anything else
|
||||
// -> `format!("{value:?}")`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn reply_line(id: u64) -> String {
|
||||
// `format!("Reply in this thread with `!approve {id}` or `!deny {id} <reason>`.")`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The posts that show `info`.
|
||||
pub fn render(info: &PendingApproval) -> Shown {
|
||||
let id = info.approval;
|
||||
let args = escape_json_text(&info.arguments);
|
||||
let header = format!(
|
||||
"**Approval {id}** · tool {} · grant {} · taint {} · expires {}",
|
||||
code(&escape_json_text(&info.tool)),
|
||||
code(&escape_json_text(&info.grant)),
|
||||
name(&info.taint),
|
||||
minute(info.expires),
|
||||
);
|
||||
let chars: Vec<char> = args.chars().collect();
|
||||
let parts: Vec<String> = if chars.is_empty() {
|
||||
vec![String::new()]
|
||||
} else {
|
||||
chars
|
||||
.chunks(PART_CHARS)
|
||||
.map(|c| c.iter().collect())
|
||||
.collect()
|
||||
};
|
||||
let count = parts.len();
|
||||
let posts: Vec<String> = if count == 1 {
|
||||
vec![format!("{header}\n{}\n{}", fence(&args), reply_line(id))]
|
||||
} else {
|
||||
let hash = sha256(args.as_bytes())
|
||||
.map(|h| h.to_hex())
|
||||
.unwrap_or_default();
|
||||
parts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, part)| {
|
||||
let n = i + 1;
|
||||
let top = if n == 1 {
|
||||
format!("{header}\nPart 1 of {count} of the arguments:")
|
||||
} else {
|
||||
format!("**Approval {id}**, part {n} of {count} of the arguments:")
|
||||
};
|
||||
let mut post = format!("{top}\n{}", fence(part));
|
||||
if n == count {
|
||||
post.push_str(&format!(
|
||||
"\nApproval {id}: {count} parts, {} characters, sha256 {hash} (of the arguments as shown).\n{}",
|
||||
chars.len(),
|
||||
reply_line(id)
|
||||
));
|
||||
}
|
||||
post
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
if count > MAX_PARTS || posts.iter().any(|p| p.chars().count() > MAX_PART_POST) {
|
||||
let hash = sha256(args.as_bytes())
|
||||
.map(|h| h.to_hex())
|
||||
.unwrap_or_default();
|
||||
let post = format!(
|
||||
"{header}\nThe arguments are {} characters (sha256 {hash}), too long to show here; answer it with `bxctl approvals`.",
|
||||
chars.len()
|
||||
);
|
||||
return Shown {
|
||||
posts: vec![post],
|
||||
answerable: false,
|
||||
};
|
||||
}
|
||||
Shown {
|
||||
posts,
|
||||
answerable: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The line added to the last post when the approval ends. `by` is `@username`, or the id.
|
||||
pub fn ended_line(ended: &Ended) -> String {
|
||||
// A `match ended`, one line each, exactly (`by` as given):
|
||||
// 1. Runs: "✅ Approved by {by}: the call runs."
|
||||
// 2. Denied: "Approved by {by}, but denied on the re-check: {name(reason)}."
|
||||
// 3. Refused with Some(reason): "❌ Refused by {by}: {code(&escape_json_text(reason))}."
|
||||
// 4. Refused with None: "❌ Refused by {by}: no reason given."
|
||||
// 5. NoLonger: "No longer pending (answered elsewhere, expired, or its turn ended)."
|
||||
// 6. Restarted: "No longer answerable here: gatewayd restarted. If it is still pending it is
|
||||
// posted again."
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The last post's text with the ending line added.
|
||||
pub fn with_ending(last: &str, ended: &Ended) -> String {
|
||||
// `format!("{last}\n\n{}", ended_line(ended))`.
|
||||
todo!()
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//! `gateway.sock`: the listener, and one thread per `brokerd` connection (M4b spec, sections 4
|
||||
//! and 6). A connection thread speaks the protocol only; everything that touches Mattermost or the
|
||||
//! state file is asked of the serve loop, which alone owns them.
|
||||
|
||||
use std::os::unix::fs::{DirBuilderExt, FileTypeExt, PermissionsExt};
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender, TryRecvError};
|
||||
use std::time::Duration;
|
||||
|
||||
use proto::{
|
||||
ApprovalPosted, ApprovalReply, Envelope, ErrorCode, FrameError, GatewayOutcome, Message,
|
||||
PROTOCOL_VERSION, PendingApproval, WireError, read_frame, write_frame,
|
||||
};
|
||||
|
||||
/// A log line printer, shared by the threads.
|
||||
pub type Log = Arc<dyn Fn(&str) + Send + Sync>;
|
||||
|
||||
/// What a connection thread asks of the serve loop.
|
||||
#[derive(Debug)]
|
||||
pub enum ToLoop {
|
||||
/// Post this approval; an accepted answer comes back on `answer`, and the post ids (or why
|
||||
/// it could not be posted) on `shown`.
|
||||
Show {
|
||||
info: PendingApproval,
|
||||
answer: Sender<ApprovalReply>,
|
||||
shown: Sender<Result<Vec<String>, String>>,
|
||||
},
|
||||
/// It ended: edit its last post, and forget it.
|
||||
Ended { approval: u64, ending: Ending },
|
||||
}
|
||||
|
||||
/// How an approval ended, as far as this `gatewayd` knows.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Ending {
|
||||
/// Answered here, by `by`, and `brokerd` said what came of it.
|
||||
Answered {
|
||||
by: String,
|
||||
reason: Option<String>,
|
||||
outcome: GatewayOutcome,
|
||||
},
|
||||
/// `brokerd` closed first, or said nothing after the answer.
|
||||
NoLonger,
|
||||
}
|
||||
|
||||
/// How long to wait: for the first frame, for the loop to post, for `brokerd`'s outcome; and how
|
||||
/// often to look for an answer meanwhile.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Timing {
|
||||
pub first_frame: Duration,
|
||||
pub posting: Duration,
|
||||
pub outcome: Duration,
|
||||
pub tick: Duration,
|
||||
}
|
||||
|
||||
impl Default for Timing {
|
||||
fn default() -> Self {
|
||||
Timing {
|
||||
first_frame: Duration::from_secs(10),
|
||||
posting: Duration::from_secs(120),
|
||||
outcome: Duration::from_secs(30),
|
||||
tick: Duration::from_millis(100),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Listen on `socket`: its directory made 0700, a stale socket removed, the socket made 0600.
|
||||
pub fn listen(socket: &Path) -> Result<UnixListener, String> {
|
||||
// 1. The directory: `socket.parent()`, which must not be empty, else "cannot prepare <socket>:
|
||||
// a socket needs a directory".
|
||||
// 2. `std::fs::DirBuilder::new().recursive(true).mode(0o700).create(dir)`, then
|
||||
// `set_permissions(dir, 0o700)`; an error is "cannot prepare <dir>: <e>".
|
||||
// 3. `std::fs::symlink_metadata(socket)`: a socket (`file_type().is_socket()`) is removed
|
||||
// (stale); anything else there is "cannot listen on <socket>: something else is there";
|
||||
// nothing there is fine.
|
||||
// 4. `UnixListener::bind(socket)`, then `set_permissions(socket, 0o600)`; an error is "cannot
|
||||
// listen on <socket>: <e>".
|
||||
// 5. Ok(listener). Paths are shown with `.display()`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Accept connections on a thread of their own, each served on its own thread.
|
||||
pub fn serve(
|
||||
listener: UnixListener,
|
||||
to_loop: Sender<ToLoop>,
|
||||
timing: Timing,
|
||||
log: Log,
|
||||
) -> std::io::Result<()> {
|
||||
// Spawn (with `std::thread::Builder`, `?`) a thread that loops over `listener.incoming()`,
|
||||
// skipping errors. For each stream: clone `to_loop` and `log`, and spawn a thread running
|
||||
// `connection(stream, &to_loop, timing, &log)`; if that spawn fails, log "gatewayd: cannot
|
||||
// start a thread for an approval: <e>". Then Ok(()).
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn frame(id: u64, last: bool, msg: Message) -> Envelope {
|
||||
Envelope {
|
||||
v: PROTOCOL_VERSION,
|
||||
id,
|
||||
r#final: last,
|
||||
msg,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every frame `brokerd` sends, read whole on a thread of its own; shutting the stream down ends it.
|
||||
fn frames(stream: &UnixStream) -> std::io::Result<Receiver<Result<Envelope, FrameError>>> {
|
||||
// As in `brokerd`'s courier: `let mut reader = stream.try_clone()?;`, a channel, and a thread
|
||||
// (`std::thread::Builder`, `?`) looping `let frame = read_frame(&mut reader); let last =
|
||||
// frame.is_err(); if tx.send(frame).is_err() || last { return; }`. Then `Ok(rx)`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// One `brokerd` connection, frames 1 to 4 of the spec's section 4.
|
||||
pub fn connection(mut stream: UnixStream, to_loop: &Sender<ToLoop>, timing: Timing, log: &Log) {
|
||||
// 1. The request, within `first_frame`.
|
||||
let _ = stream.set_read_timeout(Some(timing.first_frame));
|
||||
let info = match read_frame(&mut stream) {
|
||||
Ok(Envelope {
|
||||
id: 1,
|
||||
r#final: true,
|
||||
msg: Message::ApprovalRequest(req),
|
||||
..
|
||||
}) => req.approval,
|
||||
_ => return,
|
||||
};
|
||||
let _ = stream.set_read_timeout(None);
|
||||
let id = info.approval;
|
||||
// 2. The loop posts it.
|
||||
let (answer_tx, answer_rx) = mpsc::channel();
|
||||
let (shown_tx, shown_rx) = mpsc::channel();
|
||||
if to_loop
|
||||
.send(ToLoop::Show {
|
||||
info,
|
||||
answer: answer_tx,
|
||||
shown: shown_tx,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let posts = match shown_rx.recv_timeout(timing.posting) {
|
||||
Ok(Ok(posts)) => posts,
|
||||
Ok(Err(why)) => {
|
||||
let e = WireError {
|
||||
code: ErrorCode::Internal,
|
||||
detail: why,
|
||||
};
|
||||
let _ = write_frame(&mut stream, &frame(1, true, Message::Error(e)));
|
||||
return;
|
||||
}
|
||||
Err(_) => {
|
||||
let e = WireError {
|
||||
code: ErrorCode::Internal,
|
||||
detail: "gatewayd did not post it in time".to_string(),
|
||||
};
|
||||
let _ = write_frame(&mut stream, &frame(1, true, Message::Error(e)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let rx = match frames(&stream) {
|
||||
Ok(rx) => rx,
|
||||
Err(_) => return end(to_loop, id, Ending::NoLonger),
|
||||
};
|
||||
if write_frame(
|
||||
&mut stream,
|
||||
&frame(1, false, Message::ApprovalPosted(ApprovalPosted { posts })),
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return end(to_loop, id, Ending::NoLonger);
|
||||
}
|
||||
// 3. An answer from an approver, or brokerd closing first.
|
||||
let mut answers = Some(answer_rx);
|
||||
let reply = loop {
|
||||
if let Some(answers_rx) = &answers {
|
||||
match answers_rx.recv_timeout(timing.tick) {
|
||||
Ok(reply) => break reply,
|
||||
Err(RecvTimeoutError::Timeout) => {}
|
||||
Err(RecvTimeoutError::Disconnected) => answers = None,
|
||||
}
|
||||
} else {
|
||||
std::thread::sleep(timing.tick);
|
||||
}
|
||||
match rx.try_recv() {
|
||||
Err(TryRecvError::Empty) => {}
|
||||
// brokerd closed (the approval ended elsewhere), or sent what it may not.
|
||||
_ => {
|
||||
let _ = stream.shutdown(std::net::Shutdown::Both);
|
||||
return end(to_loop, id, Ending::NoLonger);
|
||||
}
|
||||
}
|
||||
};
|
||||
let (by, reason) = (reply.by.clone(), reply.reason.clone());
|
||||
if write_frame(&mut stream, &frame(1, true, Message::ApprovalReply(reply))).is_err() {
|
||||
return end(to_loop, id, Ending::NoLonger);
|
||||
}
|
||||
// 4. What came of it.
|
||||
let ending = match rx.recv_timeout(timing.outcome) {
|
||||
Ok(Ok(Envelope {
|
||||
id: 2,
|
||||
r#final: true,
|
||||
msg: Message::ApprovalOutcome(o),
|
||||
..
|
||||
})) => Ending::Answered {
|
||||
by,
|
||||
reason,
|
||||
outcome: o.outcome,
|
||||
},
|
||||
_ => {
|
||||
(log)(&format!(
|
||||
"gatewayd: approval {id}: brokerd said nothing after the answer"
|
||||
));
|
||||
Ending::NoLonger
|
||||
}
|
||||
};
|
||||
let _ = stream.shutdown(std::net::Shutdown::Both);
|
||||
end(to_loop, id, ending);
|
||||
}
|
||||
|
||||
fn end(to_loop: &Sender<ToLoop>, approval: u64, ending: Ending) {
|
||||
// `let _ = to_loop.send(ToLoop::Ended { approval, ending });`
|
||||
todo!()
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//! The serve loop's part in approvals (M4b spec, sections 6 and 8): posting what a `gateway.sock`
|
||||
//! connection brings, marking it when it ends, and marking what a restart left behind. The loop
|
||||
//! alone touches Mattermost and the state file; connection threads only ask.
|
||||
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use proto::{ApprovalReply, GatewayOutcome, PendingApproval};
|
||||
|
||||
use crate::approvals::Posted;
|
||||
use crate::approvals::text::{self, Ended};
|
||||
use crate::gateway::{Ending, ToLoop};
|
||||
use crate::serve::{Gateway, Stop};
|
||||
use crate::state::ApprovalPosts;
|
||||
|
||||
/// An approval's last post, the one that is edited when it ends.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Open {
|
||||
pub(super) last_post: String,
|
||||
pub(super) last_text: String,
|
||||
}
|
||||
|
||||
impl Gateway {
|
||||
/// Everything the connection threads asked since the last look, never blocking.
|
||||
pub(super) fn gateway_requests(&mut self) -> Result<(), Stop> {
|
||||
while let Ok(asked) = self.gateway_rx.try_recv() {
|
||||
match asked {
|
||||
ToLoop::Show {
|
||||
info,
|
||||
answer,
|
||||
shown,
|
||||
} => {
|
||||
let result = self.show(&info, answer)?;
|
||||
let _ = shown.send(result);
|
||||
}
|
||||
ToLoop::Ended { approval, ending } => self.ended(approval, ending)?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where an approval goes: its session's turn in flight, or the first approver's direct
|
||||
/// messages as a new thread (an empty root).
|
||||
fn place(&self, info: &PendingApproval) -> Result<(String, String), String> {
|
||||
// 1. `self.state.turn_of(info.session.as_str())`: if Some(turn), `Ok((turn.channel,
|
||||
// turn.root))`.
|
||||
// 2. Otherwise the first approver's direct messages, as a new thread:
|
||||
// `self.config.first_approver()` (None is Err("no approver is configured")), then
|
||||
// `self.client.direct_channel(&self.me.id, first)` (an Err(e) is Err(format!("no direct
|
||||
// channel with the first approver: {e}"))). `Ok((channel, String::new()))`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Post an approval's parts in order, record them, and register it for answers. An `Err`
|
||||
/// inside is why it could not be posted; the outer one stops `gatewayd` (the state file).
|
||||
fn show(
|
||||
&mut self,
|
||||
info: &PendingApproval,
|
||||
answer: Sender<ApprovalReply>,
|
||||
) -> Result<Result<Vec<String>, String>, Stop> {
|
||||
let id = info.approval;
|
||||
let (channel, mut root) = match self.place(info) {
|
||||
Ok(place) => place,
|
||||
Err(why) => return Ok(Err(why)),
|
||||
};
|
||||
let shown = text::render(info);
|
||||
let mut posts = Vec::new();
|
||||
let mut shown_at = 0;
|
||||
for body in &shown.posts {
|
||||
match self.client.create_post(&channel, &root, body) {
|
||||
Ok(post) => {
|
||||
if root.is_empty() {
|
||||
root = post.id.clone();
|
||||
}
|
||||
shown_at = post.create_at;
|
||||
posts.push(post.id);
|
||||
}
|
||||
Err(e) => {
|
||||
// Parts already posted must not look answerable.
|
||||
if let (Some(last), Some(text)) =
|
||||
(posts.last(), shown.posts.get(posts.len().wrapping_sub(1)))
|
||||
{
|
||||
self.mark(id, last, &text::with_ending(text, &Ended::NoLonger));
|
||||
}
|
||||
return Ok(Err(format!("cannot post approval {id}: {e}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.state.add_approval(ApprovalPosts {
|
||||
approval: id,
|
||||
channel: channel.clone(),
|
||||
posts: posts.clone(),
|
||||
})?;
|
||||
self.registry.add(Posted {
|
||||
approval: id,
|
||||
session: info.session.as_str().to_string(),
|
||||
channel,
|
||||
root,
|
||||
shown_at,
|
||||
answerable: shown.answerable,
|
||||
answer,
|
||||
});
|
||||
if let (Some(last_post), Some(last_text)) = (posts.last(), shown.posts.last()) {
|
||||
self.open.insert(
|
||||
id,
|
||||
Open {
|
||||
last_post: last_post.clone(),
|
||||
last_text: last_text.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(Ok(posts))
|
||||
}
|
||||
|
||||
/// An approval ended: forget it, and add how it ended to its last post.
|
||||
fn ended(&mut self, id: u64, ending: Ending) -> Result<(), Stop> {
|
||||
// 1. `self.registry.remove(id);` and `self.state.remove_approval(id)?;`.
|
||||
// 2. `let Some(open) = self.open.remove(&id) else { return Ok(()); };`
|
||||
// 3. The `Ended`: Ending::NoLonger -> Ended::NoLonger. Ending::Answered { by, reason,
|
||||
// outcome } -> `by` becomes "@<username>" from `self.client.username(&by)`, or stays the
|
||||
// id if that fails; then outcome Runs -> Ended::Runs { by }, Denied { reason } ->
|
||||
// Ended::Denied { by, reason }, Refused -> Ended::Refused { by, reason }, Gone ->
|
||||
// Ended::NoLonger.
|
||||
// 4. `self.mark(id, &open.last_post, &text::with_ending(&open.last_text, &ended));` and
|
||||
// Ok(()).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// At the first connection after a start: the approval posts the last run left are no longer
|
||||
/// answerable. Their last post is replaced by its title and the restart line.
|
||||
pub(super) fn mark_left_behind(&mut self) -> Result<(), Stop> {
|
||||
// For each `left` in `self.state.take_approvals()?`: if it has a last post,
|
||||
// `self.mark(left.approval, last, &body)` with `body = format!("**Approval {}**\n\n{}",
|
||||
// left.approval, text::ended_line(&Ended::Restarted))`. Then Ok(()).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Edit a post; a failure is logged, not retried.
|
||||
fn mark(&self, id: u64, post: &str, body: &str) {
|
||||
// `if let Err(e) = self.client.patch_post(post, body)`: log "gatewayd: cannot mark approval
|
||||
// {id} as ended: {e}" with `(self.log)(&line)`.
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user