Persona wakes on address only: chat_pending.v3 basis/form, unaddressed acked in sequence #43
14 changed files with 494 additions and 75 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ChatImage>,
|
||||
/// 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<Basis>,
|
||||
/// 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<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(self.name())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Basis {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
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<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(self.name())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Form {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<T: Serialize + DeserializeOwned + PartialEq + std::fmt::Debug>(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<ChatImage>) -> 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::<ChatPending>(PENDING_NO_IMAGE_BYTES).unwrap(), expected(None));
|
||||
assert_eq!(decode::<ChatPending>(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::<ChatPending>(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::<ChatPending>(&rmp_serde::to_vec(&value).unwrap()).is_err());
|
||||
value[1][0][12] = "tag".into();
|
||||
value[1][0][13] = "reaction".into();
|
||||
assert!(decode::<ChatPending>(&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() });
|
||||
|
|
|
|||
|
|
@ -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<Vec<ChatDelivery>> {
|
||||
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::<ChatRefusal>(&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());
|
||||
|
|
|
|||
|
|
@ -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<Option<PipelineDo
|
|||
}
|
||||
|
||||
/// The chat names of every repo she stewards in force: one query on her mind.
|
||||
fn project_names_of(mind: &dyn MindPort) -> Result<Vec<Name>> {
|
||||
pub(crate) fn project_names_of(mind: &dyn MindPort) -> Result<Vec<Name>> {
|
||||
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<Vec<Name>> {
|
|||
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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<C: ControlSource, S: ControlSource> 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<C: ControlSource, S: ControlSource> 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<C: ControlSource, S: ControlSource>(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<u64> {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -76,6 +76,8 @@ struct FakeChat {
|
|||
speaker: RefCell<String>,
|
||||
/// Acks are recorded and the deliveries stay, where several sessions share one pending.
|
||||
keep_on_ack: Cell<bool>,
|
||||
/// The mouth refuses every ack.
|
||||
fail_ack: Cell<bool>,
|
||||
/// The opens asked of the mouth, in order.
|
||||
opens: RefCell<Vec<ChatOpenDirectRequest>>,
|
||||
/// 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::<Vec<_>>(), [8, 2]);
|
||||
assert_eq!(lines.iter().map(|line| carried(line)).collect::<Vec<_>>(), [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<crate::chat_client::Basis>, 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<ChatDelivery> = (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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<ChatPendingRequest>(&document.payload).unwrap().persona) => Some(answer(pending_kind, "eureka", pending_bytes)),
|
||||
PENDING_REQUEST if for_eureka(&chat_client::decode::<ChatPendingRequest>(&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::<ChatPendingRequest>(&document.payload).unwrap().persona) => Some(answer(pending_kind, "eureka", pending_bytes)),
|
||||
SEND_REQUEST if for_eureka(&chat_client::decode::<ChatSendRequest>(&document.payload).unwrap().persona) => Some(answer(SEND_RECEIPT, &document.record_key, SEND_RECEIPT_BYTES)),
|
||||
OPEN_DIRECT_REQUEST if for_eureka(&chat_client::decode::<ChatOpenDirectRequest>(&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<String> = 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<String> = current.seen.lock().unwrap().iter().map(|(kind, _)| kind.clone()).collect();
|
||||
assert_eq!(kinds, [PENDING_REQUEST], "no v1 request follows an answered v2 request");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
BIN
crates/mind-body/tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending.v3.tag.msgpack
vendored
Normal file
BIN
crates/mind-body/tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending.v3.tag.msgpack
vendored
Normal file
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
’¦eureka
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue