gatewayd: serve, posting approvals, marking them, and the listener
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
//! 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()))`.
|
||||
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()),
|
||||
};
|
||||
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}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(()).
|
||||
self.registry.remove(id);
|
||||
self.state.remove_approval(id)?;
|
||||
let Some(open) = self.open.remove(&id) else {
|
||||
return Ok(());
|
||||
};
|
||||
let ended = match ending {
|
||||
Ending::NoLonger => Ended::NoLonger,
|
||||
Ending::Answered {
|
||||
by,
|
||||
reason: _,
|
||||
outcome,
|
||||
} => {
|
||||
let by = match self.client.username(&by) {
|
||||
Ok(username) => format!("@{username}"),
|
||||
Err(_) => by,
|
||||
};
|
||||
match outcome {
|
||||
GatewayOutcome::Runs {} => Ended::Runs { by },
|
||||
GatewayOutcome::Denied { reason } => Ended::Denied { by, reason },
|
||||
GatewayOutcome::Refused {} => Ended::Refused { by, reason: None },
|
||||
GatewayOutcome::Gone {} => Ended::NoLonger,
|
||||
}
|
||||
}
|
||||
};
|
||||
self.mark(
|
||||
id,
|
||||
&open.last_post,
|
||||
&text::with_ending(&open.last_text, &ended),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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(()).
|
||||
for left in self.state.take_approvals()? {
|
||||
let Some(last) = left.posts.last() else {
|
||||
continue;
|
||||
};
|
||||
let body = format!(
|
||||
"**Approval {}**\n\n{}",
|
||||
left.approval,
|
||||
text::ended_line(&Ended::Restarted)
|
||||
);
|
||||
self.mark(left.approval, last, &body);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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)`.
|
||||
if let Err(e) = self.client.patch_post(post, body) {
|
||||
(self.log)(&format!(
|
||||
"gatewayd: cannot mark approval {id} as ended: {e}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
//! Startup, the event loop and reconnecting (M4a spec, section 9). `run` returns only when
|
||||
//! `gatewayd` must stop: a refused token, a state file it cannot keep, or a stop asked by a test.
|
||||
|
||||
mod approvals;
|
||||
mod handle;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -12,7 +14,9 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use proto::SessionId;
|
||||
|
||||
use crate::approvals::Registry;
|
||||
use crate::config::Config;
|
||||
use crate::gateway::{self, ToLoop};
|
||||
use crate::mm::rest::Client;
|
||||
use crate::mm::{Event, Me, MmError, parse_event};
|
||||
use crate::net::Connector;
|
||||
@@ -102,6 +106,12 @@ pub(crate) struct Gateway {
|
||||
seq: u64,
|
||||
/// The turns a restart cut off have been answered.
|
||||
restarted: bool,
|
||||
/// The approvals posted here, shared with the turns (for the notice) and judged by commands.
|
||||
registry: Arc<Registry>,
|
||||
/// What `gateway.sock` connections ask of the loop.
|
||||
gateway_rx: Receiver<ToLoop>,
|
||||
/// The last post of each approval posted here and not yet ended.
|
||||
open: HashMap<u64, approvals::Open>,
|
||||
}
|
||||
|
||||
/// The wait before attempt `n` (from 0) after a loss.
|
||||
@@ -143,8 +153,21 @@ pub fn run(config: Config, token: Secret, tuning: Tuning, log: Log, stop: &Atomi
|
||||
Ok(s) => s,
|
||||
Err(e) => return Stop::State(e),
|
||||
};
|
||||
let listener = match gateway::listen(&config.gateway_socket()) {
|
||||
Ok(listener) => listener,
|
||||
Err(why) => return Stop::Start(why),
|
||||
};
|
||||
let (gateway_tx, gateway_rx) = mpsc::channel();
|
||||
if let Err(e) = gateway::serve(
|
||||
listener,
|
||||
gateway_tx,
|
||||
gateway::Timing::default(),
|
||||
Arc::clone(&log),
|
||||
) {
|
||||
return Stop::Start(format!("cannot start the gateway.sock thread: {e}"));
|
||||
}
|
||||
let client = Arc::new(Client::new(connector, token, tuning.rest_timeout));
|
||||
let mut g = Gateway::new(config, client, state, log);
|
||||
let mut g = Gateway::new(config, client, state, log, gateway_rx);
|
||||
let mut failures = 0;
|
||||
loop {
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
@@ -206,7 +229,13 @@ fn connect(config: &Config, client: &Client, tuning: &Tuning) -> Result<(Me, Ws)
|
||||
}
|
||||
|
||||
impl Gateway {
|
||||
fn new(config: Config, client: Arc<Client>, state: State, log: Log) -> Gateway {
|
||||
fn new(
|
||||
config: Config,
|
||||
client: Arc<Client>,
|
||||
state: State,
|
||||
log: Log,
|
||||
gateway_rx: Receiver<ToLoop>,
|
||||
) -> Gateway {
|
||||
let (done_tx, done_rx) = mpsc::channel();
|
||||
let limit = usize::try_from(config.limits.queue).unwrap_or(usize::MAX);
|
||||
Gateway {
|
||||
@@ -226,6 +255,9 @@ impl Gateway {
|
||||
log,
|
||||
seq: 0,
|
||||
restarted: false,
|
||||
registry: Arc::new(Registry::new()),
|
||||
gateway_rx,
|
||||
open: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +285,7 @@ impl Gateway {
|
||||
for turn in self.state.take_in_flight()? {
|
||||
self.post(&turn.channel, &turn.root, INTERRUPTED);
|
||||
}
|
||||
self.mark_left_behind()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -266,6 +299,7 @@ impl Gateway {
|
||||
return Err(Stop::Asked);
|
||||
}
|
||||
self.finished()?;
|
||||
self.gateway_requests()?;
|
||||
if last_typing.elapsed() >= typing_every {
|
||||
last_typing = Instant::now();
|
||||
if let Err(e) = self.typing(ws) {
|
||||
|
||||
Reference in New Issue
Block a user