gatewayd: approvals asked by non-approvers in direct messages go to an approver
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -7,11 +7,30 @@ use std::sync::mpsc::Sender;
|
||||
use proto::{ApprovalReply, GatewayOutcome, PendingApproval};
|
||||
|
||||
use crate::approvals::Posted;
|
||||
use crate::approvals::text::{self, Ended};
|
||||
use crate::approvals::text::{self, Asked, Ended};
|
||||
use crate::gateway::{Ending, ToLoop};
|
||||
use crate::serve::{Gateway, Stop};
|
||||
use crate::state::ApprovalPosts;
|
||||
|
||||
/// Who asked for the turn now running in a session: the author of the last message queued, the
|
||||
/// channel's type, and the turn's text. Kept in memory only, and forgotten when the turn ends.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct Asker {
|
||||
pub(super) user: String,
|
||||
pub(super) channel_type: String,
|
||||
pub(super) prompt: String,
|
||||
}
|
||||
|
||||
/// Where an approval goes, and what is posted besides it.
|
||||
struct Place {
|
||||
channel: String,
|
||||
root: String,
|
||||
/// A post before the approval's own: who asked and what, or whose session.
|
||||
first: Option<String>,
|
||||
/// The asking thread, told where the approval went.
|
||||
note: Option<(String, String)>,
|
||||
}
|
||||
|
||||
/// An approval's last post, the one that is edited when it ends.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Open {
|
||||
@@ -38,44 +57,85 @@ impl Gateway {
|
||||
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()))`.
|
||||
if let Some(turn) = self.state.turn_of(info.session.as_str()) {
|
||||
return Ok((turn.channel, turn.root));
|
||||
}
|
||||
let first = match self.config.first_approver() {
|
||||
Some(first) => first,
|
||||
None => return Err("no approver is configured".to_string()),
|
||||
/// Where an approval goes (M4b spec, section 6): its turn's thread; or, when a non-approver
|
||||
/// asked in a direct or group message, the first approver's direct messages with who asked
|
||||
/// and what, and a note in the asking thread; or, for a session not followed here, the first
|
||||
/// approver's direct messages with a line saying so.
|
||||
fn place(&self, info: &PendingApproval) -> Result<Place, String> {
|
||||
let id = info.approval;
|
||||
let session = info.session.as_str();
|
||||
let Some(turn) = self.state.turn_of(session) else {
|
||||
return Ok(Place {
|
||||
channel: self.approver_dm()?,
|
||||
root: String::new(),
|
||||
first: Some(text::unfollowed_post(id, session)),
|
||||
note: None,
|
||||
});
|
||||
};
|
||||
match self.client.direct_channel(&self.me.id, first) {
|
||||
Ok(channel) => Ok((channel, String::new())),
|
||||
Err(e) => Err(format!("no direct channel with the first approver: {e}")),
|
||||
}
|
||||
let redirect = self.askers.get(session).filter(|a| {
|
||||
matches!(a.channel_type.as_str(), "D" | "G")
|
||||
&& !self.config.approvers().contains(&a.user)
|
||||
});
|
||||
let Some(asker) = redirect else {
|
||||
return Ok(Place {
|
||||
channel: turn.channel,
|
||||
root: turn.root,
|
||||
first: None,
|
||||
note: None,
|
||||
});
|
||||
};
|
||||
let by = match self.client.username(&asker.user) {
|
||||
Ok(name) => format!("@{name}"),
|
||||
Err(_) => asker.user.clone(),
|
||||
};
|
||||
let asked = Asked {
|
||||
by,
|
||||
channel_type: asker.channel_type.clone(),
|
||||
prompt: asker.prompt.clone(),
|
||||
};
|
||||
Ok(Place {
|
||||
channel: self.approver_dm()?,
|
||||
root: String::new(),
|
||||
first: Some(text::asked_post(id, &asked)),
|
||||
note: Some((turn.channel, turn.root)),
|
||||
})
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// The first approver's direct channel with the bot.
|
||||
fn approver_dm(&self) -> Result<String, String> {
|
||||
let first = self
|
||||
.config
|
||||
.first_approver()
|
||||
.ok_or("no approver is configured")?;
|
||||
self.client
|
||||
.direct_channel(&self.me.id, first)
|
||||
.map_err(|e| format!("no direct channel with the first approver: {e}"))
|
||||
}
|
||||
|
||||
/// Post an approval's first post, if any, and its 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) {
|
||||
let place = match self.place(info) {
|
||||
Ok(place) => place,
|
||||
Err(why) => return Ok(Err(why)),
|
||||
};
|
||||
let (channel, mut root) = (place.channel, place.root);
|
||||
let shown = text::render(info);
|
||||
// A first post saying who asked, or whose session, then the approval's own posts.
|
||||
let bodies: Vec<String> = place
|
||||
.first
|
||||
.into_iter()
|
||||
.chain(shown.posts.iter().cloned())
|
||||
.collect();
|
||||
let mut posts = Vec::new();
|
||||
let mut shown_at = 0;
|
||||
for body in &shown.posts {
|
||||
for body in &bodies {
|
||||
match self.client.create_post(&channel, &root, body) {
|
||||
Ok(post) => {
|
||||
if root.is_empty() {
|
||||
@@ -87,7 +147,7 @@ impl Gateway {
|
||||
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)))
|
||||
(posts.last(), bodies.get(posts.len().wrapping_sub(1)))
|
||||
{
|
||||
self.mark(id, last, &text::with_ending(text, &Ended::NoLonger));
|
||||
}
|
||||
@@ -109,7 +169,7 @@ impl Gateway {
|
||||
answerable: shown.answerable,
|
||||
answer,
|
||||
});
|
||||
if let (Some(last_post), Some(last_text)) = (posts.last(), shown.posts.last()) {
|
||||
if let (Some(last_post), Some(last_text)) = (posts.last(), bodies.last()) {
|
||||
self.open.insert(
|
||||
id,
|
||||
Open {
|
||||
@@ -118,6 +178,9 @@ impl Gateway {
|
||||
},
|
||||
);
|
||||
}
|
||||
if let Some((note_channel, note_root)) = place.note {
|
||||
self.post(¬e_channel, ¬e_root, &text::sent_to_approver(id));
|
||||
}
|
||||
Ok(Ok(posts))
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use crate::approvals::Judged;
|
||||
use crate::deliver::{LOOP_DOWN, Notice, deliver};
|
||||
use crate::mm::{Post, typing};
|
||||
use crate::serve::approvals::Asker;
|
||||
use crate::serve::{Gateway, Stop};
|
||||
use crate::sessions::{BUSY, Batch, Ignored, Pushed, Route};
|
||||
use crate::state::InFlight;
|
||||
@@ -103,6 +104,10 @@ impl Gateway {
|
||||
}
|
||||
}
|
||||
Route::Queue(message) => {
|
||||
self.authors.insert(
|
||||
message.session.clone(),
|
||||
(post.user_id.clone(), channel_type.to_string()),
|
||||
);
|
||||
if message.joins_thread {
|
||||
self.state.join_thread(&message.thread.root)?;
|
||||
}
|
||||
@@ -136,6 +141,15 @@ impl Gateway {
|
||||
channel: batch.thread.channel.clone(),
|
||||
root: batch.thread.root.clone(),
|
||||
})?;
|
||||
if let Some((user, channel_type)) = self.authors.get(&batch.session) {
|
||||
let asker = Asker {
|
||||
user: user.clone(),
|
||||
channel_type: channel_type.clone(),
|
||||
prompt: batch.text.clone(),
|
||||
};
|
||||
self.askers
|
||||
.insert(batch.session.as_str().to_string(), asker);
|
||||
}
|
||||
let (registry, wait) = (Arc::clone(&self.registry), self.notice);
|
||||
let (client, socket, log, done) = (
|
||||
Arc::clone(&self.client),
|
||||
@@ -164,16 +178,17 @@ impl Gateway {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Turns that ended: out of flight, and the next batch of each session started.
|
||||
/// Turns that ended: out of flight, who asked forgotten, and the next batch of each session
|
||||
/// started. Never blocks.
|
||||
pub(super) fn finished(&mut self) -> Result<(), Stop> {
|
||||
// `while let Ok(session) = self.done_rx.try_recv() {` (never blocks)
|
||||
// `self.state.end_turn(session.as_str())?;`
|
||||
// `if let Some(batch) = self.queues.finish(&session) { self.start(batch)?; }` `}`
|
||||
// Then Ok(()).
|
||||
while let Ok(session) = self.done_rx.try_recv() {
|
||||
self.state.end_turn(session.as_str())?;
|
||||
if let Some(batch) = self.queues.finish(&session) {
|
||||
self.start(batch)?;
|
||||
self.askers.remove(session.as_str());
|
||||
match self.queues.finish(&session) {
|
||||
Some(batch) => self.start(batch)?,
|
||||
None => {
|
||||
self.authors.remove(&session);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -115,6 +115,10 @@ pub(crate) struct Gateway {
|
||||
gateway_rx: Receiver<ToLoop>,
|
||||
/// The last post of each approval posted here and not yet ended.
|
||||
open: HashMap<u64, approvals::Open>,
|
||||
/// The author and channel type of the last message queued in each session.
|
||||
authors: HashMap<SessionId, (String, String)>,
|
||||
/// Who asked for each turn running, by session id.
|
||||
askers: HashMap<String, approvals::Asker>,
|
||||
/// How long a turn waits for its approval to come through `gateway.sock` before the notice.
|
||||
notice: Duration,
|
||||
}
|
||||
@@ -264,6 +268,8 @@ impl Gateway {
|
||||
registry: Arc::new(Registry::new()),
|
||||
gateway_rx,
|
||||
open: HashMap::new(),
|
||||
authors: HashMap::new(),
|
||||
askers: HashMap::new(),
|
||||
notice,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user