gatewayd: images, and links in untrusted replies, are posted as code

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
2026-09-27 19:08:42 -07:00
co-authored by ornith-1.5-35b-a3b
parent 0d3048c3c4
commit 648469cd67
8 changed files with 1791 additions and 7 deletions
+36 -7
View File
@@ -10,6 +10,7 @@ use bxcore::{
write_frame,
};
use crate::links::{NOTICE, disarm};
use crate::mm::MmError;
use crate::mm::rest::Client;
use crate::sessions::{Batch, Thread};
@@ -33,7 +34,11 @@ impl Poster for Client {
/// How a turn ended.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Answer(String),
/// The reply, and whether its session has read untrusted content.
Answer {
content: String,
untrusted: bool,
},
Refused(WireError),
/// `loop.sock` could not be reached or closed early; why, for the log.
LoopDown(String),
@@ -122,6 +127,25 @@ pub fn split_answer(text: &str) -> Vec<String> {
parts
}
/// The posts of a reply: disarmed (M5b spec, section 9), then cut by `split_answer`. Each part is
/// disarmed again: a cut inside a fenced block leaves the rest of it as prose in the next post.
/// The last post ends with `NOTICE` if either pass put a link in a code span.
pub fn answer_posts(content: &str, untrusted: bool) -> Vec<String> {
let whole = disarm(content, untrusted);
let mut links = whole.links;
let mut posts = Vec::new();
for part in split_answer(&whole.text) {
let again = disarm(&part, untrusted);
links = links || again.links;
posts.push(again.text);
}
if links && let Some(last) = posts.last_mut() {
last.push_str("\n\n");
last.push_str(NOTICE);
}
posts
}
/// Send one turn and read it to its end; `on_event` sees every event.
fn one_turn(
socket: &Path,
@@ -157,8 +181,8 @@ fn one_turn(
}
// 3. `read_frame` until the end: (id 1, not final, TurnEvent) -> on_event; (1, final, TurnDone)
// -> Answer with its content; (1, final, Error) -> Refused; a read error -> LoopDown("the
// turn ended early: <e>"); anything else -> LoopDown("an unexpected frame").
// -> Answer with its content and untrusted; (1, final, Error) -> Refused; a read error ->
// LoopDown("the turn ended early: <e>"); anything else -> LoopDown("an unexpected frame").
loop {
let env = match read_frame(&mut stream) {
Ok(env) => env,
@@ -169,7 +193,12 @@ fn one_turn(
}
match (env.r#final, &env.msg) {
(false, Message::TurnEvent(e)) => on_event(e),
(true, Message::TurnDone(d)) => return Outcome::Answer(d.content.clone()),
(true, Message::TurnDone(d)) => {
return Outcome::Answer {
content: d.content.clone(),
untrusted: d.untrusted,
};
}
(true, Message::Error(w)) => return Outcome::Refused(w.clone()),
_ => return Outcome::LoopDown("an unexpected frame".to_string()),
}
@@ -209,7 +238,7 @@ pub fn deliver(
) {
// Post in the batch thread. An ApprovalPending event waits up to `notice.wait` for `brokerd` to
// bring the approval through `gateway.sock`; only when it does not, it posts the notice. Then:
// Answer -> every part of `split_answer`, in order; Refused -> `error_text`;
// Answer -> every post of `answer_posts`, in order; Refused -> `error_text`;
// LoopDown(why) -> log "gatewayd: <session>: <why>" and post LOOP_DOWN. A post that fails is
// logged, exactly "gatewayd: cannot post in <channel> (thread <root>): <error>", and the rest
// goes on.
@@ -222,8 +251,8 @@ pub fn deliver(
});
match outcome {
Outcome::Answer(content) => {
for part in split_answer(&content) {
Outcome::Answer { content, untrusted } => {
for part in answer_posts(&content, untrusted) {
post(poster, &batch.thread, part, log);
}
}