diff --git a/crates/mind-body/src/address.rs b/crates/mind-body/src/address.rs index 3197cd6..d2a9ee0 100644 --- a/crates/mind-body/src/address.rs +++ b/crates/mind-body/src/address.rs @@ -6,7 +6,7 @@ use eureka_pipeline::{OrgRepo, Short}; -use crate::chat_client::ChatDelivery; +use crate::chat_client::{Basis, ChatDelivery, Form}; /// A word a message may call a Mind or her project by. #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -46,9 +46,17 @@ pub(crate) fn localpart(matrix_id: &str) -> &str { id.split_once(':').map_or(id, |(local, _)| local) } -/// Whether a delivery is for `who`: tagged, or named by a word of hers. +/// Whether a delivery is for `who` by what it says: only a message can be (a notice or an edit never is, whatever else it says), and +/// then a tag of hers in its mentions or a word of hers in the text. What Matrix says about the Persona that polled (`Basis`) is not +/// here: it is a fact about that one Persona, not about every Mind in the table (`is_for`). pub(crate) fn addresses(who: &Addressee, delivery: &ChatDelivery) -> bool { - delivery.mentions.contains(&who.matrix_id) || who.names.iter().any(|name| names_whole_word(&delivery.text, &name.text, name.exact)) + delivery.form == Form::Message && (delivery.mentions.contains(&who.matrix_id) || who.names.iter().any(|name| names_whole_word(&delivery.text, &name.text, name.exact))) +} + +/// Whether a delivery the mouth gave `own` is for her: her DM (`is_direct`, which every mouth sends, so an old mouth's DM is hers too), +/// Matrix's word that it is a tag of hers or a reply to her, or what it says (`addresses`). The basis `Room` (`@room`) is for no one, and no basis is unproven, not "not for her": the names still decide. +pub(crate) fn is_for(own: &Addressee, delivery: &ChatDelivery) -> bool { + (delivery.form == Form::Message && (delivery.is_direct || matches!(delivery.basis, Some(Basis::Direct | Basis::Tag | Basis::Reply)))) || addresses(own, delivery) } /// The chat names of one stewarded repo. Listed names are exactly those, each matched exactly as the steward wrote it (capitalization diff --git a/crates/mind-body/src/address/tests.rs b/crates/mind-body/src/address/tests.rs index 64ae998..08abac3 100644 --- a/crates/mind-body/src/address/tests.rs +++ b/crates/mind-body/src/address/tests.rs @@ -1,6 +1,7 @@ //! The one addressing rule: a tag, or a name as a whole word; a listed name exactly as written, a derived one-word repo name as the repo spells it, a split phrase or a Mind's own in any case. use super::*; +use crate::chat_client::{Basis, Form}; fn said(text: &str, mentions: &[&str]) -> ChatDelivery { ChatDelivery { @@ -10,6 +11,8 @@ fn said(text: &str, mentions: &[&str]) -> ChatDelivery { room_alias: "swarm".into(), is_direct: false, image: None, + basis: None, + form: Form::Message, sender: "@op:chat".into(), sender_display: "Op".into(), at_unix_ms: 0, @@ -108,3 +111,29 @@ fn the_localpart_is_the_name_between_the_at_and_the_colon() { assert_eq!(localpart("@eureka:chat.gamecult.org"), "eureka"); assert_eq!(localpart("eureka"), "eureka"); } + +#[test] +fn only_a_message_can_address_and_the_mouths_basis_stands_for_the_polling_mind_alone() { + let nibu = steward("GameCult/Thing", &[]); + let with = |basis, form, text: &str, mentions: &[&str]| ChatDelivery { basis, form, ..said(text, mentions) }; + for basis in [Basis::Direct, Basis::Tag, Basis::Reply] { + assert!(is_for(&nibu, &with(Some(basis), Form::Message, "nothing named", &[])), "{basis:?}"); + assert!(!addresses(&nibu, &with(Some(basis), Form::Message, "nothing named", &[])), "a basis is a fact about the Persona that polled, not about every Mind: {basis:?}"); + assert!(!is_for(&nibu, &with(Some(basis), Form::Notice, "nothing named", &[])), "a notice is for no one: {basis:?}"); + assert!(!is_for(&nibu, &with(Some(basis), Form::Edit, "nibu, look", &["@nibu:chat"])), "nor an edit, tagged and named: {basis:?}"); + assert!(!addresses(&nibu, &with(None, Form::Edit, "nibu, look", &["@nibu:chat"])), "{basis:?}"); + } + assert!(!is_for(&nibu, &with(Some(Basis::Room), Form::Message, "nothing named", &[])), "@room is for no one"); + assert!(!is_for(&nibu, &with(None, Form::Message, "nothing named", &[])), "unproven and unnamed"); + assert!(is_for(&nibu, &with(None, Form::Message, "nibu, look", &[])), "unproven is not 'not for her': her name decides"); + assert!(is_for(&nibu, &with(Some(Basis::Room), Form::Message, "nibu, look", &[])), "an @room that also names her is named"); +} + +#[test] +fn a_direct_message_is_for_her_whatever_the_basis() { + let nibu = steward("GameCult/Thing", &[]); + let dm = ChatDelivery { is_direct: true, ..said("nothing named", &[]) }; + assert!(is_for(&nibu, &dm), "an old mouth sends no basis; the DM is hers"); + assert!(!is_for(&nibu, &ChatDelivery { form: Form::Notice, ..dm.clone() })); + assert!(!is_for(&nibu, &ChatDelivery { form: Form::Edit, ..dm })); +} diff --git a/crates/mind-body/src/chat_client.rs b/crates/mind-body/src/chat_client.rs index 58a4c12..c069920 100644 --- a/crates/mind-body/src/chat_client.rs +++ b/crates/mind-body/src/chat_client.rs @@ -4,8 +4,8 @@ //! 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 +//! by its golden fixtures (`tests/fixtures/persona-chat-v1/` for the ack and refusal kinds, `persona-chat-v2/` for the send +//! and its receipt, `persona-chat-v5/` for the pending request and its answer) 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. @@ -17,9 +17,15 @@ 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`. +/// The pending request as a mouth without the addressing facts reads it: `[persona, after_seq]`, answered with `chat_pending.v2`. +pub const PENDING_REQUEST_V1: &str = "gamecult.persona.chat_pending_request.v1"; +/// The pending request, the same tuple. Asking by this kind is asking for the answer with the addressing facts. +pub const PENDING_REQUEST: &str = "gamecult.persona.chat_pending_request.v2"; +/// The answer to it: a delivery with its `basis` and `form` at tuple indices 12 and 13. +pub const PENDING: &str = "gamecult.persona.chat_pending.v3"; +/// The answer a mouth gives a v1 request: no addressing facts, decoded as `basis: None, form: Message`. +pub const PENDING_V2: &str = "gamecult.persona.chat_pending.v2"; +/// The pending answer of a mouth that has no image field either: 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"; /// Her read receipt: the newest event of a room that went into her model's input. @@ -58,6 +64,87 @@ pub struct ChatDelivery { /// 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, + /// Why Matrix says the message is for this Persona: tuple index 12. None is unproven, never "not for her"; an answer of an + /// older mouth ends before it and reads as none. + #[serde(default)] + pub basis: Option, + /// What kind of event it is: tuple index 13. An answer of an older mouth ends before it and reads as a message. + #[serde(default)] + pub form: Form, +} + +/// Why the mouth found a delivery to be for the Persona, by what Matrix says (Bifrost `docs/bridge.md` "Addressing facts"). `Tag` and +/// `Room` are the sender's claim in `m.mentions`; `Reply` is checked against the event it targets. Evidence, not authority: the +/// runtime's own rule (`address::addresses`) weighs it. `Room` is reported and wakes no one. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Basis { + Direct, + Tag, + Reply, + Room, +} + +impl Basis { + const ALL: [Self; 4] = [Self::Direct, Self::Tag, Self::Reply, Self::Room]; + + /// The mouth's spelling: a MessagePack string. + pub fn name(self) -> &'static str { + match self { + Self::Direct => "direct", + Self::Tag => "tag", + Self::Reply => "reply", + Self::Room => "room", + } + } +} + +impl Serialize for Basis { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.name()) + } +} + +impl<'de> Deserialize<'de> for Basis { + fn deserialize>(deserializer: D) -> Result { + let name = String::deserialize(deserializer)?; + Self::ALL.into_iter().find(|basis| basis.name() == name).ok_or_else(|| serde::de::Error::custom("the mouth named a basis this side does not know")) + } +} + +/// The kind of event a delivery is. Only a `Message` can address anyone: a `Notice` is an automated line and an `Edit` revises an +/// earlier message. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Form { + #[default] + Message, + Notice, + Edit, +} + +impl Form { + const ALL: [Self; 3] = [Self::Message, Self::Notice, Self::Edit]; + + /// The mouth's spelling: a MessagePack string. + pub fn name(self) -> &'static str { + match self { + Self::Message => "message", + Self::Notice => "notice", + Self::Edit => "edit", + } + } +} + +impl Serialize for Form { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.name()) + } +} + +impl<'de> Deserialize<'de> for Form { + fn deserialize>(deserializer: D) -> Result { + let name = String::deserialize(deserializer)?; + Self::ALL.into_iter().find(|form| form.name() == name).ok_or_else(|| serde::de::Error::custom("the mouth named a form this side does not know")) + } } /// An image the mouth fetched and stored, by reference: never its bytes. The path is relative to the Persona's image diff --git a/crates/mind-body/src/chat_client/tests.rs b/crates/mind-body/src/chat_client/tests.rs index 7bba46c..8716787 100644 --- a/crates/mind-body/src/chat_client/tests.rs +++ b/crates/mind-body/src/chat_client/tests.rs @@ -10,7 +10,10 @@ const ACK_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v1/ga const PENDING_V1_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v1/gamecult.persona.chat_pending.v1.msgpack"); const PENDING_NO_IMAGE_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v3/gamecult.persona.chat_pending.v2.no-image.msgpack"); const PENDING_IMAGE_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v3/gamecult.persona.chat_pending.v2.image.msgpack"); -const PENDING_REQUEST_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v1/gamecult.persona.chat_pending_request.v1.msgpack"); +const PENDING_REQUEST_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending_request.v2.msgpack"); +const PENDING_REQUEST_V1_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v1/gamecult.persona.chat_pending_request.v1.msgpack"); +const PENDING_TAG_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending.v3.tag.msgpack"); +const PENDING_UNPROVEN_NOTICE_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending.v3.unproven-notice.msgpack"); const SENT_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v2/gamecult.persona.chat_send_receipt.v2.sent.msgpack"); const ROOM_MOVED_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v2/gamecult.persona.chat_send_receipt.v2.room-moved.msgpack"); const NIL_BASIS_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v2/gamecult.persona.chat_send_request.v2.nil-basis.msgpack"); @@ -31,6 +34,8 @@ fn golden(bytes: #[test] fn the_pending_request_and_ack_are_the_mouths_bytes() { golden(PENDING_REQUEST_BYTES, &ChatPendingRequest { persona: "eureka".into(), after_seq: 3 }); + // The v1 kind carries the same tuple: asking by the v2 kind is the only difference. + assert_eq!(PENDING_REQUEST_V1_BYTES, PENDING_REQUEST_BYTES); golden(ACK_BYTES, &ChatAck { persona: "eureka".into(), through_seq: 7 }); } @@ -48,6 +53,8 @@ fn delivery(image: Option) -> ChatDelivery { mentions: vec!["@eureka:gamecult.org".into(), "@nova:gamecult.org".into()], is_direct: true, image, + basis: None, + form: Form::Message, } } @@ -64,13 +71,44 @@ fn a_v1_pending_answer_still_decodes_with_no_image() { } #[test] -fn a_v2_pending_answer_is_the_golden_bytes_with_the_image_at_tuple_index_11() { - golden(PENDING_NO_IMAGE_BYTES, &ChatPending { persona: "eureka".into(), deliveries: vec![delivery(None)] }); - golden(PENDING_IMAGE_BYTES, &ChatPending { persona: "eureka".into(), deliveries: vec![delivery(Some(image()))] }); +fn a_v2_pending_answer_decodes_with_the_image_at_tuple_index_11_and_no_addressing_facts() { + let expected = |image| ChatPending { persona: "eureka".into(), deliveries: vec![delivery(image)] }; + assert_eq!(decode::(PENDING_NO_IMAGE_BYTES).unwrap(), expected(None)); + assert_eq!(decode::(PENDING_IMAGE_BYTES).unwrap(), expected(Some(image()))); // Index 11 of the delivery tuple, by position: 12 elements, the last an array of [path, mime, size, sha256]. - let value = shape::delivery_tuple(PENDING_IMAGE_BYTES); - assert_eq!(value, shape::Probe { delivery_len: 12, image_len: Some(4) }); + assert_eq!(shape::delivery_tuple(PENDING_IMAGE_BYTES), shape::Probe { delivery_len: 12, image_len: Some(4) }); assert_eq!(shape::delivery_tuple(PENDING_NO_IMAGE_BYTES), shape::Probe { delivery_len: 12, image_len: None }); + // A v1 request is answered with that 12-field tuple: no basis is unproven, and the event is a message. + let old = decode::(PENDING_IMAGE_BYTES).unwrap().deliveries.remove(0); + assert_eq!((old.basis, old.form), (None, Form::Message)); +} + +#[test] +fn a_v3_pending_answer_is_the_golden_bytes_with_basis_and_form_at_tuple_indices_12_and_13() { + let one = |is_direct, mentions: &[&str], basis, form| { + let mut delivery = delivery(None); + (delivery.is_direct, delivery.mentions, delivery.basis, delivery.form) = (is_direct, mentions.iter().map(|id| (*id).to_string()).collect(), basis, form); + ChatPending { persona: "eureka".into(), deliveries: vec![delivery] } + }; + golden(PENDING_TAG_BYTES, &one(false, &["@eureka:gamecult.org", "@nova:gamecult.org"], Some(Basis::Tag), Form::Message)); + golden(PENDING_UNPROVEN_NOTICE_BYTES, &one(false, &[], None, Form::Notice)); + let fields = |bytes: &[u8]| { + let value: serde_json::Value = rmp_serde::from_slice(bytes).unwrap(); + let tuple = value[1][0].as_array().unwrap().clone(); + (tuple.len(), tuple[12].clone(), tuple[13].clone()) + }; + assert_eq!(fields(PENDING_TAG_BYTES), (14, "tag".into(), "message".into())); + assert_eq!(fields(PENDING_UNPROVEN_NOTICE_BYTES), (14, serde_json::Value::Null, "notice".into())); +} + +#[test] +fn a_basis_or_form_this_side_does_not_know_does_not_decode() { + let mut value: serde_json::Value = rmp_serde::from_slice(PENDING_TAG_BYTES).unwrap(); + value[1][0][12] = "everyone".into(); + assert!(decode::(&rmp_serde::to_vec(&value).unwrap()).is_err()); + value[1][0][12] = "tag".into(); + value[1][0][13] = "reaction".into(); + assert!(decode::(&rmp_serde::to_vec(&value).unwrap()).is_err()); } /// Reads the tuple's shape from the raw bytes, independent of the serde types under test. @@ -104,15 +142,6 @@ fn a_send_and_its_receipt_are_the_mouths_bytes() { golden(ROOM_MOVED_BYTES, &ChatSendReceipt { request_id: "req-1".into(), event_id: None, refusal: Some(Refusal::RoomMoved), newer: vec!["$a".into(), "$b".into()] }); } -/// Writes the v2 fixtures; run once with `WRITE_CHAT_FIXTURES=1` when the wire changes. -#[test] -#[ignore = "regenerates the golden fixtures"] -fn write_the_v2_pending_fixtures() { - let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/persona-chat-v3/"); - std::fs::write(format!("{dir}gamecult.persona.chat_pending.v2.no-image.msgpack"), encode(&ChatPending { persona: "eureka".into(), deliveries: vec![delivery(None)] }).unwrap()).unwrap(); - std::fs::write(format!("{dir}gamecult.persona.chat_pending.v2.image.msgpack"), encode(&ChatPending { persona: "eureka".into(), deliveries: vec![delivery(Some(image()))] }).unwrap()).unwrap(); -} - #[test] fn open_direct_fixtures_round_trip() { golden(OPEN_REQUEST_BYTES, &ChatOpenDirectRequest { request_id: "req-1".into(), persona: "eureka".into(), user_id: "@meta:gamecult.org".into(), text: "hello Meta".into() }); diff --git a/crates/mind-body/src/chat_rudp.rs b/crates/mind-body/src/chat_rudp.rs index 89384f9..8d12836 100644 --- a/crates/mind-body/src/chat_rudp.rs +++ b/crates/mind-body/src/chat_rudp.rs @@ -29,7 +29,7 @@ use serde::de::DeserializeOwned; use sha2::{Digest, Sha256}; use crate::chat_client::{ - ACK, Chat, ChatAck, ChatDelivery, ChatOpenDirectReceipt, ChatOpenDirectRequest, ChatPending, ChatPendingRequest, ChatRead, ChatRefusal, MouthRefused, Refusal, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, OPEN_DIRECT_RECEIPT, OPEN_DIRECT_REQUEST, PENDING_V1, READ, REFUSAL, SEND_RECEIPT, + ACK, Chat, ChatAck, ChatDelivery, ChatOpenDirectReceipt, ChatOpenDirectRequest, ChatPending, ChatPendingRequest, ChatRead, ChatRefusal, MouthRefused, Refusal, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, PENDING_REQUEST_V1, PENDING_V2, OPEN_DIRECT_RECEIPT, OPEN_DIRECT_REQUEST, PENDING_V1, READ, REFUSAL, SEND_RECEIPT, SEND_REQUEST, decode, encode, }; @@ -161,7 +161,12 @@ impl Chat for RudpChat { fn pending(&self, after_seq: u64) -> Result> { let request = ChatPendingRequest { persona: self.persona.clone(), after_seq }; let p = self.persona.as_str(); - let (kind, payload) = self.exchange(PENDING_REQUEST, p, &request, Some(&[(PENDING, p), (PENDING_V1, p), (REFUSAL, p)]))?.ok_or_else(|| anyhow!("no answer payload"))?; + let answers = [(PENDING, p), (PENDING_V2, p), (PENDING_V1, p), (REFUSAL, p)]; + let (mut kind, mut payload) = self.exchange(PENDING_REQUEST, p, &request, Some(&answers))?.ok_or_else(|| anyhow!("no answer payload"))?; + // A mouth that does not serve the v2 request answers `UnknownKind`: ask it the v1 way, and its deliveries carry no addressing facts. + if kind == REFUSAL && decode::(&payload)?.refusal == Refusal::UnknownKind { + (kind, payload) = self.exchange(PENDING_REQUEST_V1, p, &request, Some(&answers))?.ok_or_else(|| anyhow!("no answer payload"))?; + } if kind == REFUSAL { let refusal: ChatRefusal = decode(&payload)?; bail!("the mouth refused the pending request: {}", refusal.refusal.name()); diff --git a/crates/mind-body/src/heartbeat.rs b/crates/mind-body/src/heartbeat.rs index 67d5d42..1d281e3 100644 --- a/crates/mind-body/src/heartbeat.rs +++ b/crates/mind-body/src/heartbeat.rs @@ -39,7 +39,7 @@ use cultnet_rs::Selection; use eureka_pipeline::{PipelineKind, PipelineRef, ResolutionOutcome, RunOperator, RunTurn, Slug}; use huginn_mind::{PipelineDocumentSummary, PipelineFacts, PipelinePageItems}; -use crate::address::{Addressee, Name, addresses, project_names}; +use crate::address::{Addressee, Name, is_for, project_names}; use crate::chat_client::{Chat, ChatDelivery}; use crate::control::{ControlSource, Effective, FileSource, HeldReason, SwarmEffective, read_effective, read_envelope, set_mode_world_readable}; use crate::launch::{Clock, LaunchOutcome, LaunchRequest, Launcher, Ports, open_and_launch, slot}; @@ -503,7 +503,7 @@ fn newest(mind: &dyn MindPort, selection: Selection) -> Result Result> { +pub(crate) fn project_names_of(mind: &dyn MindPort) -> Result> { let mut names = Vec::new(); for header in headers(mind, &in_force(of_kind(PipelineKind::Stewardship)))? { if let PipelineFacts::Stewardship { repo, names: listed, .. } = &header.facts { @@ -518,7 +518,7 @@ fn project_names_of(mind: &dyn MindPort) -> Result> { fn wakes(config: &Config, who: &Addressee, delivery: &ChatDelivery) -> bool { let from_operator = config.operators.contains(&delivery.sender); let from_other_mind = delivery.sender != who.matrix_id() && config.personas.iter().any(|mind| mind.matrix_id == delivery.sender); - (from_operator || from_other_mind) && addresses(who, delivery) + (from_operator || from_other_mind) && is_for(who, delivery) } /// The mouth pages a pending answer, so fewer deliveries may come than wait. @@ -633,7 +633,7 @@ impl Row<'_> { /// A member (not the operator) has a pending message that names or tags her. fn addressed_by_member(&self, config: &Config) -> bool { - self.pending.iter().any(|delivery| !config.operators.contains(&delivery.sender) && addresses(&self.addressee, delivery)) + self.pending.iter().any(|delivery| !config.operators.contains(&delivery.sender) && is_for(&self.addressee, delivery)) } } diff --git a/crates/mind-body/src/heartbeat/tests.rs b/crates/mind-body/src/heartbeat/tests.rs index 091b018..c9bdb39 100644 --- a/crates/mind-body/src/heartbeat/tests.rs +++ b/crates/mind-body/src/heartbeat/tests.rs @@ -12,6 +12,7 @@ use chrono::{Duration, TimeZone}; use eureka_pipeline::PipelineKind as K; use huginn_mind::PipelineSelectionPage; use tempfile::TempDir; +use crate::address::addresses; use super::*; use crate::control::{Brake, ControlState, SwarmState}; @@ -161,6 +162,8 @@ fn delivery(seq: u64, sender: &str, text: &str, mentions: &[&str]) -> ChatDelive mentions: mentions.iter().map(|id| (*id).to_string()).collect(), is_direct: false, image: None, + basis: None, + form: crate::chat_client::Form::Message, } } @@ -1556,3 +1559,34 @@ fn a_sleeping_mind_is_not_eligible_for_the_beat() { assert_eq!(rig.poll(), Outcome::Polled { launched: vec![Slug("ann".into()), Slug("bob".into())] }); assert_eq!(started_turns(&rig), vec![("ann".to_string(), RunTurn::Sleep), ("bob".to_string(), RunTurn::PersonaTurn)], "the beat went to the Mind who was awake, though ann's urgency was the higher"); } + +/// Matrix's word that a message is her DM, a tag or a reply wakes her off the beat from the operator; a notice or an edit never does, +/// whatever its basis, and a stranger's direct message is delivered but wakes no one. +#[test] +fn matrixs_addressing_facts_wake_her_off_the_beat_only_on_a_message_from_the_roster() { + use crate::chat_client::{Basis, Form}; + let with = |seq, sender: &str, basis, form| ChatDelivery { basis, form, ..delivery(seq, sender, "no name here", &[]) }; + for (basis, form, wakes) in [ + (Some(Basis::Direct), Form::Message, true), + (Some(Basis::Tag), Form::Message, true), + (Some(Basis::Reply), Form::Message, true), + (Some(Basis::Room), Form::Message, false), + (None, Form::Message, false), + (Some(Basis::Direct), Form::Notice, false), + (Some(Basis::Tag), Form::Edit, false), + ] { + let rig = Rig::new(); + rig.turn(ANN, 10); + rig.turn(BOB, 10); + rig.store.save(&HeartbeatState { last_beat_at: rig.now(), handled_mentions: vec![] }).unwrap(); + rig.say(BOB, with(1, "@op:chat", basis, form)); + let launched = if wakes { vec![Slug("bob".into())] } else { vec![] }; + assert_eq!(rig.poll(), Outcome::Polled { launched }, "{basis:?} {form:?}"); + } + let rig = Rig::new(); + rig.turn(ANN, 10); + rig.turn(BOB, 10); + rig.store.save(&HeartbeatState { last_beat_at: rig.now(), handled_mentions: vec![] }).unwrap(); + rig.say(BOB, with(1, "@stranger:chat", Some(Basis::Direct), Form::Message)); + assert_eq!(rig.poll(), Outcome::Polled { launched: vec![] }, "the sender gate stays: a stranger's DM is delivered and wakes no one off the beat"); +} diff --git a/crates/mind-body/src/persona.rs b/crates/mind-body/src/persona.rs index 9405664..8ab67ca 100644 --- a/crates/mind-body/src/persona.rs +++ b/crates/mind-body/src/persona.rs @@ -84,12 +84,12 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use uuid::Uuid; -use crate::address::{Addressee, Name, addresses, localpart, project_names}; +use crate::address::{Addressee, Name, addresses, is_for, localpart, project_names}; use crate::chat_image::{self, Image, MAX_PER_LINE}; use crate::chat_client::{Chat, ChatDelivery, MouthRefused, ChatOpenDirectRequest, ChatRead, DirectOutcome, ChatSendRequest, ContextLine, Refusal, SendBasis}; use crate::connector::render_plan; use crate::control::{ControlSource, Effective, HeldReason, derive}; -use crate::heartbeat::{HeartbeatView, PollTrace}; +use crate::heartbeat::{HeartbeatView, PollTrace, project_names_of}; use crate::lease_client::{Lease, LeaseRequest, Lender, Lent}; use crate::launch::{Clock, LaunchOutcome, LaunchRequest, Launcher, Ports, holders, open_and_launch}; use crate::queue::{MindPort, any_of, citing, headers, in_force, live_claims, of_kind}; @@ -886,13 +886,26 @@ pub fn carry(organs: &Organs, control: &impl ControlSource, swarm: &impl Control progress.failure = Some(Failure::Held(reason)); break; } - let Ok(pending) = organs.chat.pending(progress.acked) else { + let Ok(mut pending) = organs.chat.pending(progress.acked) else { progress.failure = Some(Failure::Chat); break; }; // A mind that cannot be asked for her Self's replies still serves her chat. let routes = reply_routes(organs.mind, &BTreeSet::new()).unwrap_or_default(); let addressing = addressing_of(config, organs.heartbeat, &own_addressee(config)); + pending.sort_by_key(|delivery| delivery.seq); + // Her names as the heartbeat's table has them, and her projects' names as her mind holds them now: the table is saved after the + // heartbeat launches her, so it may not yet hold a project a message names. + let table = addressing.iter().find(|mind| mind.matrix_id() == config.own_sender).cloned().unwrap_or_else(|| own_addressee(config)); + let fresh = own_addressee(config).with_projects(project_names_of(organs.mind).unwrap_or_default()); + if let Some(through) = quiet_through(&[&table, &fresh], &pending, 0) { + if organs.chat.ack(through).is_err() { + progress.failure = Some(Failure::Ack); + break; + } + progress.acked = through; + pending.retain(|delivery| delivery.seq > through); + } let Some(session) = next_session(config, &addressing, pending, &routes, &mut opened_alone, process, unix_ms(organs.clock.now())) else { break }; let remaining_s = i64::try_from(organs.run_cap_s).unwrap_or(i64::MAX).saturating_sub((organs.clock.now() - started).num_seconds()); if process > 0 && remaining_s <= ROTATE_MARGIN_S { @@ -1593,6 +1606,22 @@ impl SessionInbox for PersonaInbox<'_, C, S> self.note_repo_changes(); let mut pending = self.organs.chat.pending(self.progress.acked)?; pending.sort_by_key(|delivery| delivery.seq); + // Whom a delivery is for is the heartbeat's table as it stands, her own row first: it knows her projects' names before her + // first line has read them. + if !pending.is_empty() { + self.addressing = addressing_of(self.config, self.organs.heartbeat, &self.addressee); + } + let row = self.addressing.iter().find(|mind| mind.matrix_id() == self.config.own_sender).cloned().unwrap_or_else(|| self.addressee.clone()); + let own = [&row, &self.addressee]; + // Not while a stale send waits for the messages that interrupted it: they are what the revision line shows her. A failed ack is + // tried again at the next poll; it is not a reason to end the session. + if self.revision.is_none() + && let Some(through) = quiet_through(&own, &pending, self.told_through) + && self.organs.chat.ack(through).is_ok() + { + self.progress.acked = through; + pending.retain(|delivery| delivery.seq > through); + } if let Some(revision) = &mut self.revision { revision.polls += 1; revision.ready |= revision.polls >= REVISION_POLLS || revision.awaiting.iter().all(|id| pending.iter().any(|delivery| delivery.event_id == *id)); @@ -1611,10 +1640,7 @@ impl SessionInbox for PersonaInbox<'_, C, S> self.first_seen.entry(delivery.seq).or_insert(now); } let any_untold = !untold.is_empty(); - if any_untold { - self.addressing = addressing_of(self.config, self.organs.heartbeat, &self.addressee); - } - let due = revision_ready || untold.iter().any(|delivery| now >= self.first_seen[&delivery.seq] + stagger_ms(self.config, &self.addressing, delivery)); + let due = revision_ready || untold.iter().any(|delivery| own.iter().any(|who| is_for(who, delivery)) && now >= self.first_seen[&delivery.seq] + stagger_ms(self.config, &self.addressing, delivery)); self.waiting = if due { one_line_of(untold, |batch| message_lines(self.config, &self.addressee, &self.addressing, batch, &self.earlier(batch)).len()) } else { Vec::new() }; Ok(self.polled(!self.waiting.is_empty() || self.retell.is_some() || revision_ready || !self.notices.is_empty() || !self.waiting_replies.is_empty(), any_untold)) } @@ -1972,6 +1998,16 @@ fn act(inbox: &PersonaInbox<'_, C, S>, batch Ok(Step::Answered(started, reply.intent, opened, notice)) } +/// The seq an ack may pass because nothing in it is for her: the leading run of `pending` (oldest first) that is not addressed to any of +/// `own` (her rows as the heartbeat's table, her projects' names and her session know them) and lies above `told_through`, so no +/// delivery she was told and has not answered is passed. None when the front is addressed, or already told. Such a delivery wakes and +/// tells no one; it is acked in sequence so the mouth's position moves (it stops reading at 500 unacked) and is not lost: Matrix keeps +/// it, and the `context` of the next addressed delivery shows it. +fn quiet_through(own: &[&Addressee], pending: &[ChatDelivery], told_through: u64) -> Option { + let run: Vec<&ChatDelivery> = pending.iter().take_while(|delivery| delivery.seq > told_through && !own.iter().any(|who| is_for(who, delivery))).collect(); + run.last().map(|last| last.seq) +} + /// How long after a poll first sees `delivery` a live session is told it: none in a direct message, otherwise her rank times the /// step. The Minds the message is for (tagged, named, or named by a project, by `address::addresses` over the swarm's addressing) /// rank first, by a hash of the Mind and the event, and the rest follow them, ranked the same way: the first addressed Mind hears @@ -1986,7 +2022,7 @@ fn stagger_ms(config: &Config, addressing: &[Addressee], delivery: &ChatDelivery let mut minds: Vec<&Addressee> = addressing.iter().filter(|mind| mind.matrix_id() != delivery.sender).collect(); minds.sort_unstable_by_key(|mind| mind.matrix_id()); minds.dedup_by_key(|mind| mind.matrix_id()); - let (addressed, others): (Vec<&Addressee>, Vec<&Addressee>) = minds.into_iter().partition(|mind| addresses(mind, delivery)); + let (addressed, others): (Vec<&Addressee>, Vec<&Addressee>) = minds.into_iter().partition(|mind| if mind.matrix_id() == config.own_sender { is_for(mind, delivery) } else { addresses(mind, delivery) }); let own_is_addressed = addressed.iter().any(|mind| mind.matrix_id() == config.own_sender); let (set, before) = if own_is_addressed { (&addressed, 0) } else { (&others, addressed.len()) }; let mine = (stagger_hash(&config.own_sender, &delivery.event_id), config.own_sender.as_str()); @@ -2725,7 +2761,7 @@ struct Addressees { } fn addressees(own: &Addressee, addressing: &[Addressee], delivery: &ChatDelivery, names: &BTreeMap<&str, &str>) -> Addressees { - let to_you = addresses(own, delivery); + let to_you = is_for(own, delivery); let mut ids: Vec<&str> = delivery.mentions.iter().map(String::as_str).filter(|id| *id != own.matrix_id()).collect(); for row in addressing { let id = row.matrix_id(); diff --git a/crates/mind-body/src/persona/tests.rs b/crates/mind-body/src/persona/tests.rs index 19e42f0..f56bad3 100644 --- a/crates/mind-body/src/persona/tests.rs +++ b/crates/mind-body/src/persona/tests.rs @@ -76,6 +76,8 @@ struct FakeChat { speaker: RefCell, /// Acks are recorded and the deliveries stay, where several sessions share one pending. keep_on_ack: Cell, + /// The mouth refuses every ack. + fail_ack: Cell, /// The opens asked of the mouth, in order. opens: RefCell>, /// How the fake mouth answers an open; none is a transport error. @@ -169,6 +171,9 @@ impl Chat for FakeChat { } fn ack(&self, through_seq: u64) -> Result<()> { + if self.fail_ack.get() { + return Err(anyhow!("the mouth is down")); + } self.log.borrow_mut().push("ack".into()); self.acked.borrow_mut().push(through_seq); if !self.keep_on_ack.get() { @@ -395,7 +400,8 @@ fn config() -> Config { fn delivery(seq: u64, room: &str, sender: &str, text: &str) -> ChatDelivery { let ranked_first = |event_id: &str| stagger(&config(), &own_addressee(&config()), &ChatDelivery { event_id: event_id.into(), ..delivery_of(seq, room, sender, text, event_id) }) == 0; let event_id = (0..1000).map(|n| if n == 0 { format!("$e{seq}") } else { format!("$e{seq}-{n}") }).find(|id| ranked_first(id)).expect("among 1000 events one ranks her first: the stagger ranks nobody first"); - delivery_of(seq, room, sender, text, &event_id) + // A message the mouth found to be for her (a tag), so a session is told it; `delivery_of` is the bare one, with no basis. + ChatDelivery { basis: Some(crate::chat_client::Basis::Tag), ..delivery_of(seq, room, sender, text, &event_id) } } /// The stagger of `delivery` for a Mind whose own addressee is `own`, with no heartbeat table: the configured Minds by tag and localpart. @@ -403,6 +409,11 @@ fn stagger(config: &Config, own: &Addressee, delivery: &ChatDelivery) -> u64 { stagger_ms(config, &addressing_of(config, None, own), delivery) } +/// The same message with no basis: what the mouth could not prove is hers, left to her names. +fn plain(delivery: ChatDelivery) -> ChatDelivery { + ChatDelivery { basis: None, ..delivery } +} + fn delivery_of(seq: u64, room: &str, sender: &str, text: &str, event_id: &str) -> ChatDelivery { ChatDelivery { seq, @@ -417,6 +428,8 @@ fn delivery_of(seq: u64, room: &str, sender: &str, text: &str, event_id: &str) - mentions: vec![], is_direct: false, image: None, + basis: None, + form: crate::chat_client::Form::Message, } } @@ -451,6 +464,7 @@ impl Rig { landed: RefCell::default(), speaker: RefCell::new("@eureka:chat".into()), keep_on_ack: Cell::new(false), + fail_ack: Cell::new(false), opens: RefCell::default(), open_outcome: Cell::new(Some(DirectOutcome::Opened)), open_unserved: Cell::new(false), @@ -949,12 +963,12 @@ fn a_persona_that_is_absent_or_a_session_that_fails_fails_the_start() { #[test] fn senders_are_typed_by_the_organ_and_the_trace_keeps_each_items_provenance() { - let mut earlier = delivery(2, "swarm", "@guest:chat", "ship it"); + let mut earlier = ChatDelivery { basis: None, ..delivery(2, "swarm", "@guest:chat", "ship it") }; earlier.context = vec![ContextLine { sender: "@eureka:chat".into(), text: "I said wait".into(), at_unix_ms: 1 }]; let rig = Rig::new(vec![operator_says(1, "do the thing"), earlier], &[says(&[("operator", "done")])]); rig.go(); let input = rig.model.requests.borrow()[0].input.clone(); - assert!(input.contains(r#"message room="operator" sender="@op:chat" role=operator name="op" at=2023-11-14T22:13:20Z text="do the thing""#), "{input}"); + assert!(input.contains(r#"message room="operator" sender="@op:chat" role=operator name="op" to_you=true at=2023-11-14T22:13:20Z text="do the thing""#), "{input}"); assert!(input.contains(r#"message room="swarm" sender="@guest:chat" role=member name="guest" at=2023-11-14T22:13:20Z text="ship it""#), "{input}"); assert!(input.contains(r#"earlier room="swarm" sender="@eureka:chat" role=persona at=1970-01-01T00:00:00Z text="I said wait""#), "{input}"); let trace = rig.trace.0.borrow(); @@ -1273,10 +1287,10 @@ fn her_responsibilities_show_each_stewardships_chat_names_and_the_line_on_changi fn a_message_naming_her_project_or_her_name_renders_to_you_and_to_lists_tags_only() { let rig = Rig::new( vec![ - delivery(1, "swarm", "@op:chat", "is the bad day simulator green?"), - delivery(2, "swarm", "@op:chat", "Eureka, a word"), - delivery(3, "swarm", "@op:chat", "a bad day, simulated"), - ChatDelivery { mentions: vec!["@aqua:chat".into()], ..delivery(4, "swarm", "@op:chat", "BadDaySimulator, Aqua?") }, + plain(delivery(1, "swarm", "@op:chat", "Eureka, a word")), + plain(delivery(2, "swarm", "@op:chat", "is the bad day simulator green?")), + plain(delivery(3, "swarm", "@op:chat", "a bad day, simulated")), + ChatDelivery { mentions: vec!["@aqua:chat".into()], ..plain(delivery(4, "swarm", "@op:chat", "BadDaySimulator, Aqua?")) }, ], &[says(&[])], ); @@ -1287,7 +1301,7 @@ fn a_message_naming_her_project_or_her_name_renders_to_you_and_to_lists_tags_onl assert!(line("a word").contains(" to_you=true "), "{input}"); assert!(!line("simulated").contains("to_you"), "{input}"); assert!(line("Aqua?").contains(" to_you=true to=[\"@aqua:chat\"] "), "the tag stays in `to`, hers is not listed: {input}"); - let other = message_lines(&config(), &own_addressee(&config()), &[], &[delivery(1, "swarm", "@op:chat", "is the bad day simulator green?")], &[vec![]]); + let other = message_lines(&config(), &own_addressee(&config()), &[], &[plain(delivery(1, "swarm", "@op:chat", "is the bad day simulator green?"))], &[vec![]]); assert!(!other.contains("to_you"), "another Mind is not addressed by a project she does not steward: {other}"); } @@ -1347,9 +1361,9 @@ fn the_responsibilities_hold_their_bound_and_mark_what_they_drop() { } fn input_for(mentions: &[&str], display: &str) -> String { - let mut aqua = delivery(1, "swarm", "@aqua:chat", "hello"); + let mut aqua = ChatDelivery { basis: None, ..delivery(1, "swarm", "@aqua:chat", "hello") }; aqua.sender_display = display.into(); - let mut said = delivery(2, "swarm", "@operator:chat", "a reply"); + let mut said = ChatDelivery { basis: None, ..delivery(2, "swarm", "@operator:chat", "a reply") }; said.mentions = mentions.iter().map(|m| (*m).to_string()).collect(); let c = config(); let batch = [aqua, said]; @@ -2279,7 +2293,7 @@ fn a_dm_memory_names_its_confidant_not_its_subject() { fn about_can_name_an_absent_mind() { let (libby, op) = ("@libby:chat", "@op:chat"); let decided = reply(&[("!dm-op:chat", "ok")], json!([keeps("Decided", "Libby does the shaders for the Cinny fork", &[libby])]), json!([]), json!(null)); - let dm = ChatDelivery { is_direct: true, room_id: "!dm-op:chat".into(), ..delivery_of(1, "!dm-op:chat", op, "Libby will do the shaders for the Cinny fork", "$dm-libby") }; + let dm = ChatDelivery { is_direct: true, basis: Some(crate::chat_client::Basis::Direct), room_id: "!dm-op:chat".into(), ..delivery_of(1, "!dm-op:chat", op, "Libby will do the shaders for the Cinny fork", "$dm-libby") }; let rig = Rig::new(vec![dm], &[decided, says(&[("swarm", "go")])]); assert_eq!(failure_of(rig.go()), None); assert_eq!(rig.sent_texts().len(), 1); @@ -3938,7 +3952,7 @@ fn a_first_line_is_the_whole_line_before_this_cut_but_for_what_it_says_it_gained assert_eq!(TURN_INSTRUCTION.len(), 2382 + GAINED.len(), "the instruction's length, and what it gained"); let rig = a_small_machine(); assert_eq!(failure_of(rig.go()), None); - let expected = header_of_this_cut(&with_chat_names(BASE_SMALL_MACHINE)).replace(" text=", " at=2023-11-14T22:13:20Z text="); + let expected = header_of_this_cut(&with_chat_names(BASE_SMALL_MACHINE)).replace(" text=", " at=2023-11-14T22:13:20Z text=").replace("name=\"libby\" at=", "name=\"libby\" to_you=true at="); assert_eq!(input_of(&rig, 0).strip_prefix(TURN_INSTRUCTION).unwrap(), expected); let bare = Rig::new(vec![], &[says(&[])]); assert_eq!(failure_of(bare.go()), None); @@ -4069,7 +4083,7 @@ fn a_tell_is_only_the_message_at_live_shape() { println!("TELL-CHARS first={} later={}", whole.chars().count(), tell.chars().count()); assert!(whole.len() > 25 * 1024, "{}", whole.len()); assert!(whole.starts_with(TURN_INSTRUCTION) && whole.contains("MINE") && whole.contains("line 19 ")); - assert_eq!(tell, format!("message room=\"swarm\" sender=\"{libby}\" role=member name=\"libby\" at=2023-11-14T22:13:20Z text=\"{body}\"\n\n{WORK_TAIL}\n"), "the message and its envelope, then the question of her work, and nothing else"); + assert_eq!(tell, format!("message room=\"swarm\" sender=\"{libby}\" role=member name=\"libby\" to_you=true at=2023-11-14T22:13:20Z text=\"{body}\"\n\n{WORK_TAIL}\n"), "the message and its envelope, then the question of her work, and nothing else"); } #[test] @@ -4939,7 +4953,7 @@ fn a_burst_is_told_over_lines_within_the_bound() { let lines = told(&rig); assert_eq!(lines.len(), 3, "{}", lines.len()); let carried = |line: &str| line.lines().filter(|line| line.starts_with("message ")).count(); - assert_eq!((carried(&lines[1]), carried(&lines[2])), (8, 2), "32 KiB of rendered 4000 B messages, the rest next"); + assert_eq!((carried(&lines[1]), carried(&lines[2])), (7, 3), "32 KiB of rendered 4000 B messages, the rest next"); assert_eq!(rig.chat.acked.borrow().last(), Some(&11)); } @@ -4951,7 +4965,7 @@ fn a_burst_waiting_at_a_start_is_told_over_lines_within_the_bound() { assert_eq!(failure_of(rig.go()), None); let lines = told(&rig); let carried = |line: &str| line.lines().filter(|line| line.starts_with("message ")).count(); - assert_eq!(lines.iter().map(|line| carried(line)).collect::>(), [8, 2]); + assert_eq!(lines.iter().map(|line| carried(line)).collect::>(), [7, 3]); assert_eq!(rig.chat.acked.borrow().last(), Some(&10)); } @@ -6458,16 +6472,17 @@ fn an_addressed_message_is_told_at_once_and_an_unaddressed_one_waits_her_rank() let config = three_minds("@eureka:chat"); let plain = unaddressed_at_rank(1, 2, "who takes the cellar?"); let addressed = ChatDelivery { mentions: vec!["@eureka:chat".into()], ..plain.clone() }; - let in_dm = ChatDelivery { is_direct: true, ..plain.clone() }; + let in_dm = ChatDelivery { is_direct: true, basis: Some(crate::chat_client::Basis::Direct), ..plain.clone() }; + let second = addressed_at_rank_one(2, "who takes the cellar?"); assert_eq!(STAGGER_STEP_S, 8, "the step is the calibration the ruling names"); - for (name, message, wait_ms) in [("addressed", addressed, 0), ("direct", in_dm, 0), ("unaddressed", plain, 16_000)] { + for (name, message, wait_ms) in [("addressed", addressed, 0), ("direct", in_dm, 0), ("addressed second", second, 8_000)] { let rig = Rig::new(vec![], &[]); rig.clock.step.set(0); let t0 = rig.clock.now.get(); arrives(&rig, &message); with_inbox(&rig, &config, vec![message.clone()], |inbox| { if wait_ms > 0 { - for waited in [0, 8_000, 15_999] { + for waited in [0, 4_000, 7_999] { rig.clock.now.set(t0 + waited); assert!(!inbox.poll().unwrap().pending, "{name}: told after {waited} ms of {wait_ms}"); } @@ -6478,6 +6493,26 @@ fn an_addressed_message_is_told_at_once_and_an_unaddressed_one_waits_her_rank() assert_eq!(inbox.tell(false).unwrap(), expected, "{name}: the tell is the message and its envelope, unchanged by the wait"); }); } + // The ruling `addressed-delivery-only`: a message that is not hers is never told by waiting. + let rig = Rig::new(vec![], &[]); + rig.clock.step.set(0); + let t0 = rig.clock.now.get(); + arrives(&rig, &plain); + with_inbox(&rig, &config, vec![plain.clone()], |inbox| { + for waited in [0, 16_000, 3_600_000] { + rig.clock.now.set(t0 + waited); + assert!(!inbox.poll().unwrap().pending, "told after {waited} ms though it is not hers"); + } + }); +} + +/// An addressed message that two Minds are tagged in, whose event ranks her second: she hears it one step after the first. +fn addressed_at_rank_one(seq: u64, text: &str) -> ChatDelivery { + let config = three_minds("@eureka:chat"); + (0..1000) + .map(|n| ChatDelivery { mentions: vec!["@eureka:chat".into(), "@libby:chat".into()], basis: Some(crate::chat_client::Basis::Tag), ..delivery_of(seq, "swarm", "@op:chat", text, &format!("$s{seq}-{n}")) }) + .find(|candidate| stagger(&config, &own_addressee(&config), candidate) == STAGGER_STEP_S * 1000) + .expect("among 1000 events one ranks her second") } #[test] @@ -6486,22 +6521,22 @@ fn the_wait_is_bounded_under_a_stream_of_messages() { rig.clock.step.set(0); let config = three_minds("@eureka:chat"); let t0 = rig.clock.now.get(); - arrives(&rig, &unaddressed_at_rank(1, 2, "the first")); + arrives(&rig, &addressed_at_rank_one(1, "the first")); with_inbox(&rig, &config, vec![], |inbox| { let mut told_at = None; for second in 0..=40i64 { rig.clock.now.set(t0 + second * 1000); if second > 0 && second % 3 == 0 { - arrives(&rig, &unaddressed_at_rank(u64::try_from(second).unwrap() + 1, 2, &format!("more {second}"))); + arrives(&rig, &addressed_at_rank_one(u64::try_from(second).unwrap() + 1, &format!("more {second}"))); } if inbox.poll().unwrap().pending { told_at = Some(second); break; } } - assert_eq!(told_at, Some(16), "the first message's clock is its own: newer messages do not move it"); + assert_eq!(told_at, Some(8), "the first message's clock is its own: newer messages do not move it"); let line = inbox.tell(false).unwrap(); - assert!(line.contains("the first") && line.contains("more 15"), "{line}"); + assert!(line.contains("the first") && line.contains("more 6"), "{line}"); }); } @@ -7021,10 +7056,12 @@ fn a_poll_with_untold_messages_reads_the_heartbeats_table_again() { let mut inbox = PersonaInbox::new(&organs, &control, &swarm, &config, &run, &mut progress, session_of(&config, vec![]), started_ms, String::new()); let message = unaddressed_at_rank(1, 2, "who takes the bad day simulator?"); arrives(&rig, &message); - assert!(!inbox.poll().unwrap().pending, "no table: unaddressed, she waits her rank"); + assert!(!inbox.poll().unwrap().pending, "no table: not hers, and nothing makes it hers by waiting"); let mut minds = eight_minds(&[("@eureka:chat", "GameCult/BadDaySimulator", &[])]); minds.truncate(1); *view.0.borrow_mut() = Some(crate::heartbeat::Addressing { minds }); + // The first was not hers and is acked; the heartbeat now says a project of hers is named, and she is told the next at once. + arrives(&rig, &unaddressed_at_rank(2, 2, "who takes the bad day simulator?")); assert!(inbox.poll().unwrap().pending, "the heartbeat now says the message names her project: she is told at once"); } @@ -7904,3 +7941,121 @@ fn the_schema_a_session_runs_under_admits_an_open_only_in_a_public_session() { assert_eq!(failure_of(private.go()), None); assert_eq!(schema_of(&private)["properties"]["open_direct"], json!({ "type": "null" })); } + +fn heard(seq: u64, text: &str, basis: Option, form: crate::chat_client::Form) -> ChatDelivery { + ChatDelivery { basis, form, ..delivery_of(seq, "swarm", "@libby:chat", text, &format!("$q{seq}")) } +} + +/// The ruling `addressed-delivery-only`: what Matrix and the names do not make hers is acked in sequence and wakes and tells no one. +#[test] +fn a_leading_run_of_what_is_not_hers_is_quiet_and_an_ack_never_passes_what_is_told() { + use crate::chat_client::{Basis, Form}; + let own = own_addressee(&config()); + let room = |seq| heard(seq, "anyone around", Some(Basis::Room), Form::Message); + let unproven = |seq| heard(seq, "anyone around", None, Form::Message); + assert_eq!(quiet_through(&[&own], &[unproven(1), room(2), unproven(3)], 0), Some(3), "a nil basis and an @room are not hers"); + let hers = heard(4, "anyone around", Some(Basis::Reply), Form::Message); + assert_eq!(quiet_through(&[&own], &[unproven(1), hers.clone(), unproven(5)], 0), Some(1), "the run stops at the first that is hers"); + assert_eq!(quiet_through(&[&own], &[hers, unproven(5)], 0), None); + assert_eq!(quiet_through(&[&own], &[unproven(1), unproven(2)], 1), None, "the front was told and is not answered: nothing passes it"); + assert_eq!(quiet_through(&[&own], &[unproven(5), unproven(6)], 5), None); + assert_eq!(quiet_through(&[&own], &[], 0), None); +} + +/// A notice or an edit never addresses her, whatever else it says; a nil basis does not mean "not for her": her name still does. +#[test] +fn a_notice_and_an_edit_are_quiet_whatever_they_say_and_a_nil_basis_with_her_name_is_hers() { + use crate::chat_client::{Basis, Form}; + let own = own_addressee(&config()); + for (basis, form) in [(Some(Basis::Direct), Form::Notice), (Some(Basis::Tag), Form::Edit), (Some(Basis::Reply), Form::Notice), (None, Form::Edit)] { + let mut line = heard(1, "eureka, look", basis, form); + line.mentions = vec!["@eureka:chat".into()]; + assert_eq!(quiet_through(&[&own], &[line], 0), Some(1), "{basis:?} {form:?}"); + } + assert_eq!(quiet_through(&[&own], &[heard(1, "Eureka, look", None, Form::Message)], 0), None, "an old mouth's delivery, named in the text"); + assert_eq!(quiet_through(&[&own], &[heard(1, "look", Some(Basis::Direct), Form::Message)], 0), None); +} + +/// Two unaddressed lines ahead of an addressed one are acked at the start, in one ack, and the turn is told the addressed one alone. +#[test] +fn unaddressed_deliveries_are_acked_in_sequence_ahead_of_the_addressed_one_that_wakes_her() { + use crate::chat_client::{Basis, Form}; + let rig = Rig::new(vec![heard(1, "lunch anyone", None, Form::Message), heard(2, "@room ping", Some(Basis::Room), Form::Message), operator_says(3, "three")], &[]); + rig.clock.step.set(0); + let (child, writes) = answering(&rig, reply_saying("ok")); + let (outcome, acked) = carry_children(&rig, vec![child]); + assert_eq!(failure_of(outcome), None); + assert_eq!(acked, [2, 3], "the quiet run first, then the addressed one once answered"); + let told = first_line(&writes); + assert!(told.contains("three") && !told.contains("lunch anyone") && !told.contains("@room ping"), "{told}"); +} + +/// The mouth stops reading at 500 unacked deliveries: 600 that are none of hers cost one ack, and no turn is told any of them. +#[test] +fn five_hundred_unaddressed_deliveries_are_one_ack_and_tell_no_one() { + use crate::chat_client::Form; + let flood: Vec = (1..=600).map(|seq| heard(seq, "chatter", None, Form::Message)).collect(); + let rig = Rig::new(flood, &[]); + rig.clock.step.set(0); + let (child, writes) = answering(&rig, empty_reply()); + let (_, acked) = carry_children(&rig, vec![child]); + assert_eq!(acked, [600]); + assert!(!first_line(&writes).contains("chatter")); +} + +/// An answer of an old mouth (no basis, every event a message) wakes her on her name and on a tag in its mentions, as it did. +#[test] +fn a_v1_mouths_deliveries_still_wake_her_by_name_and_by_tag() { + use crate::chat_client::Form; + let mut tagged = heard(2, "look", None, Form::Message); + tagged.mentions = vec!["@eureka:chat".into()]; + let rig = Rig::new(vec![heard(1, "Eureka, look", None, Form::Message), tagged], &[]); + rig.clock.step.set(0); + let (child, writes) = answering(&rig, reply_saying("ok")); + let (outcome, acked) = carry_children(&rig, vec![child]); + assert_eq!(failure_of(outcome), None); + assert_eq!(acked, [2]); + assert!(first_line(&writes).contains("Eureka, look")); +} + +/// An old mouth sends no basis, but every mouth sends `is_direct`: a DM that names no one is hers, is told, and is not acked as quiet. +#[test] +fn a_direct_message_from_an_old_mouth_is_hers_with_no_basis() { + use crate::chat_client::Form; + let dm = ChatDelivery { is_direct: true, ..heard(1, "can you look at this", None, Form::Message) }; + assert_eq!(quiet_through(&[&own_addressee(&config())], &[dm.clone()], 0), None); + let rig = Rig::new(vec![dm], &[says(&[("operator", "ok")])]); + rig.clock.step.set(0); + let (child, writes) = answering(&rig, reply_saying("ok")); + let (_, acked) = carry_children(&rig, vec![child]); + assert!(first_line(&writes).contains("can you look at this")); + assert_eq!(acked, [1]); +} + +/// A message naming only a project she stewards is hers by her mind's records, though no heartbeat table holds the name yet: it is told, not +/// acked as quiet at the start. +#[test] +fn a_message_naming_only_her_project_is_told_though_no_table_holds_the_name() { + use crate::chat_client::Form; + let rig = Rig::new(vec![heard(1, "is the bad day simulator green?", None, Form::Message)], &[says(&[])]); + rig.mind.committed(vec![steward_naming("GameCult/BadDaySimulator", &[])]); + rig.clock.step.set(0); + let (child, writes) = answering(&rig, reply_saying("ok")); + let (_, acked) = carry_children(&rig, vec![child]); + assert!(first_line(&writes).contains("bad day simulator"), "{}", first_line(&writes)); + assert_eq!(acked, [1], "acked once, after it was answered"); +} + +/// A refused ack at the start ends the start with the ack failure, and nothing is told. +#[test] +fn a_refused_quiet_ack_ends_the_start_and_tells_nothing() { + use crate::chat_client::Form; + let rig = Rig::new(vec![heard(1, "chatter", None, Form::Message), heard(2, "more chatter", None, Form::Message)], &[]); + rig.chat.fail_ack.set(true); + rig.clock.step.set(0); + let (child, writes) = answering(&rig, empty_reply()); + let (outcome, acked) = carry_children(&rig, vec![child]); + assert_eq!(failure_of(outcome), Some(Failure::Ack)); + assert!(acked.is_empty()); + assert!(writes.borrow().is_empty()); +} diff --git a/crates/mind-body/tests/chat_rudp.rs b/crates/mind-body/tests/chat_rudp.rs index 927062f..1c92f6b 100644 --- a/crates/mind-body/tests/chat_rudp.rs +++ b/crates/mind-body/tests/chat_rudp.rs @@ -13,13 +13,15 @@ use std::thread::JoinHandle; use std::time::Duration; use cultnet_rs::{CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions}; -use mind_body::chat_client::{self, ChatRefusal, MouthRefused, Refusal, REFUSAL, ACK, Chat, ChatAck, ChatOpenDirectReceipt, ChatOpenDirectRequest, DirectOutcome, OPEN_DIRECT_RECEIPT, OPEN_DIRECT_REQUEST, ChatPending, ChatPendingRequest, ChatRead, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, PENDING_V1, READ, SEND_RECEIPT, SEND_REQUEST}; +use mind_body::chat_client::{self, Basis, Form, ChatRefusal, MouthRefused, Refusal, REFUSAL, ACK, Chat, ChatAck, ChatOpenDirectReceipt, ChatOpenDirectRequest, DirectOutcome, OPEN_DIRECT_RECEIPT, OPEN_DIRECT_REQUEST, ChatPending, ChatPendingRequest, ChatRead, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, PENDING_REQUEST_V1, PENDING_V1, PENDING_V2, READ, SEND_RECEIPT, SEND_REQUEST}; use mind_body::chat_rudp::{RudpChat, connection_id}; const ACK_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v1/gamecult.persona.chat_ack.v1.msgpack"); const PENDING_V1_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v1/gamecult.persona.chat_pending.v1.msgpack"); -const PENDING_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v3/gamecult.persona.chat_pending.v2.image.msgpack"); -const PENDING_REQUEST_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v1/gamecult.persona.chat_pending_request.v1.msgpack"); +const PENDING_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v5/gamecult.persona.chat_pending.v3.tag.msgpack"); +const PENDING_V2_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v3/gamecult.persona.chat_pending.v2.image.msgpack"); +const PENDING_REQUEST_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v5/gamecult.persona.chat_pending_request.v2.msgpack"); +const PENDING_REQUEST_V1_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v1/gamecult.persona.chat_pending_request.v1.msgpack"); const SEND_RECEIPT_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v2/gamecult.persona.chat_send_receipt.v2.sent.msgpack"); const SEND_REQUEST_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v2/gamecult.persona.chat_send_request.v2.nil-basis.msgpack"); const OPEN_REQUEST_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v4/gamecult.persona.chat_open_direct_request.v1.msgpack"); @@ -73,11 +75,16 @@ fn mouth(key: &str) -> Mouth { mouth_at(key, "127.0.0.1:0") } -/// A mouth that has no image field yet: it answers a pending request in the v1 kind. +/// A mouth that has no image field yet: it answers a pending request in the v1 kind, and does not serve the v2 request. fn legacy_mouth(key: &str) -> Mouth { serve(key, "127.0.0.1:0", PENDING_V1, PENDING_V1_BYTES, (OPEN_DIRECT_RECEIPT, None, OPENED_BYTES)) } +/// A mouth that has the image field and not the addressing facts: it answers the v1 request in the v2 kind, and does not serve the v2 request. +fn imaged_mouth(key: &str) -> Mouth { + serve(key, "127.0.0.1:0", PENDING_V2, PENDING_V2_BYTES, (OPEN_DIRECT_RECEIPT, None, OPENED_BYTES)) +} + /// The same mouth on a chosen address: a restart binds the address the old one held. fn mouth_at(key: &str, bind: &str) -> Mouth { serve(key, bind, PENDING, PENDING_BYTES, (OPEN_DIRECT_RECEIPT, None, OPENED_BYTES)) @@ -101,7 +108,10 @@ fn serve(key: &str, bind: &str, pending_kind: &'static str, pending_bytes: &'sta log.lock().unwrap().push((document.schema_id.clone(), document.payload.clone())); let for_eureka = |persona: &str| persona == "eureka"; let reply = match document.schema_id.as_str() { - PENDING_REQUEST if for_eureka(&chat_client::decode::(&document.payload).unwrap().persona) => Some(answer(pending_kind, "eureka", pending_bytes)), + PENDING_REQUEST if for_eureka(&chat_client::decode::(&document.payload).unwrap().persona) => { + Some(if pending_kind == PENDING { answer(pending_kind, "eureka", pending_bytes) } else { answer(REFUSAL, "eureka", UNKNOWN_KIND_BYTES) }) + } + PENDING_REQUEST_V1 if pending_kind != PENDING && for_eureka(&chat_client::decode::(&document.payload).unwrap().persona) => Some(answer(pending_kind, "eureka", pending_bytes)), SEND_REQUEST if for_eureka(&chat_client::decode::(&document.payload).unwrap().persona) => Some(answer(SEND_RECEIPT, &document.record_key, SEND_RECEIPT_BYTES)), OPEN_DIRECT_REQUEST if for_eureka(&chat_client::decode::(&document.payload).unwrap().persona) => Some(answer(open.0, open.1.unwrap_or(&document.record_key), open.2)), _ => None, @@ -211,15 +221,25 @@ fn a_send_that_timed_out_is_not_repeated_by_the_client() { } #[test] -fn a_v1_mouths_pending_answer_still_reads_with_no_image_and_a_v2_one_carries_it() { - let legacy = legacy_mouth(KEY); - let old = client(&legacy, KEY, "eureka", Duration::from_millis(1500)).pending(0).unwrap(); - assert_eq!(old.len(), 1); - assert!(old[0].image.is_none()); +fn a_mouth_without_the_v2_request_is_asked_the_v1_way_and_its_deliveries_carry_no_addressing_facts() { + for (mouth, text) in [(legacy_mouth(KEY), "v1 answer"), (imaged_mouth(KEY), "v2 answer")] { + let old = client(&mouth, KEY, "eureka", Duration::from_millis(1500)).pending(3).unwrap(); + assert_eq!(old.len(), 1, "{text}"); + assert_eq!((old[0].basis, old[0].form), (None, Form::Message), "{text}"); + let seen: Vec = mouth.seen.lock().unwrap().iter().map(|(kind, _)| kind.clone()).collect(); + assert_eq!(seen, [PENDING_REQUEST, PENDING_REQUEST_V1], "{text}: the v2 request first, refused as an unknown kind, then the v1 request"); + assert_eq!(mouth.seen.lock().unwrap()[1].1, PENDING_REQUEST_V1_BYTES, "{text}"); + } + assert!(client(&imaged_mouth(KEY), KEY, "eureka", Duration::from_millis(1500)).pending(0).unwrap()[0].image.is_some()); +} + +#[test] +fn a_current_mouth_answers_the_v2_request_with_basis_and_form() { let current = mouth(KEY); - let new = client(¤t, KEY, "eureka", Duration::from_millis(1500)).pending(0).unwrap(); - assert_eq!(new[0].image.as_ref().map(|image| (image.mime.as_str(), image.size)), Some(("image/png", 2048))); - assert_eq!(new[0].text, old[0].text, "the same message, with its attachment"); + let new = client(¤t, KEY, "eureka", Duration::from_millis(1500)).pending(3).unwrap(); + assert_eq!((new[0].basis, new[0].form), (Some(Basis::Tag), Form::Message)); + let kinds: Vec = current.seen.lock().unwrap().iter().map(|(kind, _)| kind.clone()).collect(); + assert_eq!(kinds, [PENDING_REQUEST], "no v1 request follows an answered v2 request"); } #[test] diff --git a/crates/mind-body/tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending.v3.tag.msgpack b/crates/mind-body/tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending.v3.tag.msgpack new file mode 100644 index 0000000..006f6cf Binary files /dev/null and b/crates/mind-body/tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending.v3.tag.msgpack differ diff --git a/crates/mind-body/tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending.v3.unproven-notice.msgpack b/crates/mind-body/tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending.v3.unproven-notice.msgpack new file mode 100644 index 0000000..069d3f9 Binary files /dev/null and b/crates/mind-body/tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending.v3.unproven-notice.msgpack differ diff --git a/crates/mind-body/tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending_request.v2.msgpack b/crates/mind-body/tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending_request.v2.msgpack new file mode 100644 index 0000000..0c326f5 --- /dev/null +++ b/crates/mind-body/tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending_request.v2.msgpack @@ -0,0 +1 @@ +’¦eureka \ No newline at end of file diff --git a/crates/mind-body/tests/public_surface.txt b/crates/mind-body/tests/public_surface.txt index 1f16654..dd6bc32 100644 --- a/crates/mind-body/tests/public_surface.txt +++ b/crates/mind-body/tests/public_surface.txt @@ -14,6 +14,8 @@ mind_body: mod tempo mind_body::address: impl Addressee mind_body::address: struct Addressee # [derive (Clone , Debug , PartialEq , Eq , serde :: Serialize , serde :: Deserialize)] pub-fields[] mind_body::address: struct Name # [derive (Clone , Debug , PartialEq , Eq , serde :: Serialize , serde :: Deserialize)] pub-fields[] +mind_body::chat_client: # [derive (Clone , Copy , Debug , Default , PartialEq , Eq)] pub enum Form { # [default] Message , Notice , Edit , } +mind_body::chat_client: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum Basis { Direct , Tag , Reply , Room , } mind_body::chat_client: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum DirectOutcome { Opened , Existing , Brake , PersonaMismatch , UnknownPersona , EmptyText , ForeignUser , NotAPerson , NoSuchUser , InvitePending , Declined , DailyLimit , SendFailed , } mind_body::chat_client: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum Refusal { Brake , PersonaMismatch , UnknownRoom , EmptyText , SendFailed , UnknownPersona , RoomMoved , UnknownKind , } mind_body::chat_client: const ACK : & str @@ -21,25 +23,37 @@ mind_body::chat_client: const OPEN_DIRECT_RECEIPT : & str mind_body::chat_client: const OPEN_DIRECT_REQUEST : & str mind_body::chat_client: const PENDING : & str mind_body::chat_client: const PENDING_REQUEST : & str +mind_body::chat_client: const PENDING_REQUEST_V1 : & str mind_body::chat_client: const PENDING_V1 : & str +mind_body::chat_client: const PENDING_V2 : & str mind_body::chat_client: const READ : & str mind_body::chat_client: const REFUSAL : & str mind_body::chat_client: const SEND_RECEIPT : & str mind_body::chat_client: const SEND_REQUEST : & str mind_body::chat_client: fn decode < T : DeserializeOwned > (bytes : & [u8]) -> Result < T > mind_body::chat_client: fn encode < T : Serialize > (document : & T) -> Result < Vec < u8 > > +mind_body::chat_client: impl Basis +mind_body::chat_client: impl Basis: fn name (self) -> & 'static str mind_body::chat_client: impl DirectOutcome mind_body::chat_client: impl DirectOutcome: fn name (self) -> & 'static str +mind_body::chat_client: impl Form +mind_body::chat_client: impl Form: fn name (self) -> & 'static str mind_body::chat_client: impl Refusal mind_body::chat_client: impl Refusal: fn name (self) -> & 'static str +mind_body::chat_client: impl Serialize for Basis mind_body::chat_client: impl Serialize for DirectOutcome +mind_body::chat_client: impl Serialize for Form mind_body::chat_client: impl Serialize for Refusal mind_body::chat_client: impl TryFrom < RawOpenReceipt > for ChatOpenDirectReceipt +mind_body::chat_client: impl std :: error :: Error for MouthRefused +mind_body::chat_client: impl std :: fmt :: Display for MouthRefused +mind_body::chat_client: impl< 'de > Deserialize < 'de > for Basis mind_body::chat_client: impl< 'de > Deserialize < 'de > for DirectOutcome +mind_body::chat_client: impl< 'de > Deserialize < 'de > for Form mind_body::chat_client: impl< 'de > Deserialize < 'de > for Refusal mind_body::chat_client: pub trait Chat { fn pending (& self , after_seq : u64) -> Result < Vec < ChatDelivery > > ; fn send (& self , request : & ChatSendRequest) -> Result < ChatSendReceipt > ; fn open_direct (& self , request : & ChatOpenDirectRequest) -> Result < ChatOpenDirectReceipt > ; fn ack (& self , through_seq : u64) -> Result < () > ; fn read (& self , read : & ChatRead) -> Result < () > ; } mind_body::chat_client: struct ChatAck # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[persona: String, through_seq: u64] -mind_body::chat_client: struct ChatDelivery # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[seq: u64, room_alias: String, room_id: String, event_id: String, sender: String, sender_display: String, text: String, at_unix_ms: u64, context: Vec < ContextLine >, mentions: Vec < String >, is_direct: bool, image: Option < ChatImage >] +mind_body::chat_client: struct ChatDelivery # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[seq: u64, room_alias: String, room_id: String, event_id: String, sender: String, sender_display: String, text: String, at_unix_ms: u64, context: Vec < ContextLine >, mentions: Vec < String >, is_direct: bool, image: Option < ChatImage >, basis: Option < Basis >, form: Form] mind_body::chat_client: struct ChatImage # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[path: String, mime: String, size: u64, sha256: String] mind_body::chat_client: struct ChatOpenDirectReceipt # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[request_id: String, outcome: DirectOutcome, room_id: Option < String >, event_id: Option < String >] mind_body::chat_client: struct ChatOpenDirectRequest # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[request_id: String, persona: String, user_id: String, text: String] @@ -50,6 +64,7 @@ mind_body::chat_client: struct ChatRefusal # [derive (Clone , Debug , PartialEq mind_body::chat_client: struct ChatSendReceipt # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[request_id: String, event_id: Option < String >, refusal: Option < Refusal >, newer: Vec < String >] mind_body::chat_client: struct ChatSendRequest # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[request_id: String, persona: String, room_alias: String, text: String, basis: Option < SendBasis >] mind_body::chat_client: struct ContextLine # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[sender: String, text: String, at_unix_ms: u64] +mind_body::chat_client: struct MouthRefused # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub-fields[0: Refusal] mind_body::chat_client: struct SendBasis # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[seen_event_id: Option < String >, since_unix_ms: u64] mind_body::chat_image: impl Skip mind_body::chat_rudp: fn connection_id (key_file_text : & str) -> u32