Huginn/crates/mind-body/src/chat_client.rs
Eureka e2c9638f27 mind-body: a delivered chat image reaches her session as an image block
ChatDelivery gains tuple index 11, `image: Option<ChatImage>` ([path, mime,
size, sha256], a reference into her per-room image store, never bytes); the
pending kind is gamecult.persona.chat_pending.v2, and v1 answers and records
still decode with no image. chat_image::load is the one place a reference
becomes bytes: exact `<hex sha256 of room id>/<sha256>[.ext]` path with no
traversal or symlink, regular file, 5 MiB declared and actual, declared size
and sha256, png/jpeg/gif/webp magic bytes matching the declared mime. The
persona line carries the image as a base64 image block after its text and an
`attachment role=organ` line saying attached or not shown and why. The
stream-json echo of a line with blocks still counts as our echo. The store is
MIND_PERSONA_IMAGE_STORE; unset, every image is not shown.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 20:52:30 +00:00

193 lines
7.6 KiB
Rust

//! The chat wire of Bifrost's Persona mouth, as typed documents and the one port
//! her Persona organ reads and writes chat through.
//!
//! The mouth is Bifrost's chat ear and mouth (cut `persona-mouth`). Five CultNet document kinds cross
//! it, each a positional MessagePack tuple (fields in declaration order, `rmp_serde::to_vec`; the mouth refuses maps): the pending request and its answer, the
//! ack, and the send request with its receipt. Their field order is the mouth's, pinned
//! by its golden fixtures (`tests/fixtures/persona-chat-v1/` for the pending, ack and refusal kinds, `persona-chat-v2/` for the send
//! and its receipt) in both directions. `chat_rudp::RudpChat` is
//! the RUDP transport that carries them; `Chat` is its seam, and `encode` and
//! `decode` are its codec. Room aliases are the mouth's
//! full aliases (`#operator:gamecult.org`), which the caller's `--rooms` carries.
//!
//! No chat-server client, token or address exists on this side: the Persona
//! process holds no chat credential.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
/// Document kinds, as the mouth names them.
pub const PENDING_REQUEST: &str = "gamecult.persona.chat_pending_request.v1";
pub const PENDING: &str = "gamecult.persona.chat_pending.v2";
/// The pending answer of a mouth that has no image field yet: its deliveries decode with `image: None`.
pub const PENDING_V1: &str = "gamecult.persona.chat_pending.v1";
pub const ACK: &str = "gamecult.persona.chat_ack.v1";
pub const SEND_REQUEST: &str = "gamecult.persona.chat_send_request.v2";
pub const SEND_RECEIPT: &str = "gamecult.persona.chat_send_receipt.v2";
pub const REFUSAL: &str = "gamecult.persona.chat_refusal.v1";
/// One earlier line of a room, delivered with a message for its context.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContextLine {
pub sender: String,
pub text: String,
pub at_unix_ms: u64,
}
/// One message from another sender in a joined room.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChatDelivery {
pub seq: u64,
pub room_alias: String,
pub room_id: String,
pub event_id: String,
pub sender: String,
pub sender_display: String,
pub text: String,
pub at_unix_ms: u64,
pub context: Vec<ContextLine>,
/// The Matrix user ids the event's `m.mentions` names, as the mouth carries them.
pub mentions: Vec<String>,
/// Whether the room is a direct message.
pub is_direct: bool,
/// The attachment the mouth stored for an image event. Tuple index 11; a v1 record ends at 10 and reads as none.
#[serde(default)]
pub image: Option<ChatImage>,
}
/// An image the mouth fetched and stored, by reference: never its bytes. The path is relative to the Persona's image
/// store, `<hex sha256 of the room id>/<hex sha256 of the bytes>[.<ext>]`, and every field is re-checked against the
/// file before it reaches a model (`chat_image`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChatImage {
pub path: String,
pub mime: String,
pub size: u64,
pub sha256: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChatPendingRequest {
pub persona: String,
pub after_seq: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChatPending {
pub persona: String,
pub deliveries: Vec<ChatDelivery>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChatAck {
pub persona: String,
pub through_seq: u64,
}
/// Why the mouth refused a request, as its typed names (Bifrost
/// `tools/persona-chat-documents.mjs` `refusals`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Refusal {
Brake,
PersonaMismatch,
UnknownRoom,
EmptyText,
SendFailed,
UnknownPersona,
/// A send with a basis found the room had moved past it: the receipt names what moved it.
RoomMoved,
}
impl Refusal {
/// The mouth's spelling: a MessagePack string, whatever the codec does with unit variants.
pub fn name(self) -> &'static str {
match self {
Self::Brake => "Brake",
Self::PersonaMismatch => "PersonaMismatch",
Self::UnknownRoom => "UnknownRoom",
Self::EmptyText => "EmptyText",
Self::SendFailed => "SendFailed",
Self::UnknownPersona => "UnknownPersona",
Self::RoomMoved => "RoomMoved",
}
}
}
impl Serialize for Refusal {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.name())
}
}
impl<'de> Deserialize<'de> for Refusal {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let name = String::deserialize(deserializer)?;
[Self::Brake, Self::PersonaMismatch, Self::UnknownRoom, Self::EmptyText, Self::SendFailed, Self::UnknownPersona, Self::RoomMoved]
.into_iter()
.find(|refusal| refusal.name() == name)
.ok_or_else(|| serde::de::Error::custom("the mouth named a refusal this side does not know"))
}
}
/// The mouth's typed answer to a request that names a Persona it does not
/// serve: the pending request, the ack and the send alike.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChatRefusal {
pub persona: String,
pub refusal: Refusal,
}
/// What a send's author had seen of its room: the newest event of the room her input held (none when it held
/// none), and when her process began. The mouth refuses the send if another sender's message reached the room
/// after that event, or after that time where there is none.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SendBasis {
pub seen_event_id: Option<String>,
pub since_unix_ms: u64,
}
/// A send. `request_id` is the mouth's transaction id, so a repeat of the same
/// id is the same send. No `basis` is her choice standing: the mouth checks nothing.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChatSendRequest {
pub request_id: String,
pub persona: String,
pub room_alias: String,
pub text: String,
pub basis: Option<SendBasis>,
}
/// The mouth's answer to a send: the event it made, or why it did not. `newer` names the events that moved the
/// room, oldest first, exactly when the refusal is `RoomMoved`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChatSendReceipt {
pub request_id: String,
pub event_id: Option<String>,
pub refusal: Option<Refusal>,
pub newer: Vec<String>,
}
/// A document as the mouth reads it: a positional tuple.
pub fn encode<T: Serialize>(document: &T) -> Result<Vec<u8>> {
rmp_serde::to_vec(document).context("the chat document does not encode")
}
/// A document as the mouth writes it.
pub fn decode<T: DeserializeOwned>(bytes: &[u8]) -> Result<T> {
rmp_serde::from_slice(bytes).context("the chat document does not decode")
}
/// The Persona organ's chat: what is waiting, what to say, what is done.
pub trait Chat {
/// Deliveries after `after_seq`, oldest first. A `chat_refusal` answer is an
/// error that names the refusal.
fn pending(&self, after_seq: u64) -> Result<Vec<ChatDelivery>>;
fn send(&self, request: &ChatSendRequest) -> Result<ChatSendReceipt>;
/// Every delivery up to and including `through_seq` is answered; the mouth
/// advances its sync position only now, so a start that dies first has its
/// deliveries again.
fn ack(&self, through_seq: u64) -> Result<()>;
}
#[cfg(test)]
mod tests;