//! Bounded normalization and prompt rendering for room-local Matrix context.
use std::fmt::Write as _;
use serde_json::Value;
use crate::{config::ContextConfig, models::MatrixMessage};
/// Builds chronological, bounded context from events preceding an invocation.
#[must_use]
pub fn buildContext(
room_id: &str,
invocation: &Value,
events_before_newest_first: Vec<Value>,
limits: &ContextConfig,
) -> Vec<MatrixMessage> {
let Some(invocation) = normalizeEvent(room_id, invocation, limits.max_event_characters) else {
return Vec::new();
};
let mut selected = vec![invocation];
let mut character_count = selected[0].body.chars().count();
for event in events_before_newest_first {
if selected.len() >= limits.max_events as usize
|| character_count >= limits.max_characters as usize
{
break;
}
let remaining = limits.max_characters as usize - character_count;
let remaining = u32::try_from(remaining).unwrap_or(u32::MAX);
let Some(message) =
normalizeEvent(room_id, &event, limits.max_event_characters.min(remaining))
else {
continue;
};
character_count += message.body.chars().count();
selected.push(message);
}
selected.reverse();
selected
}
/// Renders normalized room context as explicitly delimited untrusted data.
#[must_use]
pub fn renderContext(messages: &[MatrixMessage]) -> String {
let mut output = String::from("<room_context>\n");
for (index, message) in messages.iter().enumerate() {
let sequence = index + 1;
let _ = write!(
output,
"<message sequence=\"{sequence}\" event_id=\"{}\" author_id=\"{}\" timestamp_ms=\"{}\">\n{}\n",
escape_attribute(&message.event_id),
escape_attribute(&message.sender_id),
message.timestamp_ms,
message.body,
);
if let Some(reply_to) = &message.reply_to_event_id {
let _ = writeln!(
output,
"<reply_to event_id=\"{}\" />",
escape_attribute(reply_to)
);
}
output.push_str("</message>\n");
}
output.push_str("</room_context>");
output
}
/// Normalizes a Matrix text event without retaining executable HTML.
#[must_use]
pub fn normalizeEvent(
room_id: &str,
event: &Value,
maximum_characters: u32,
) -> Option<MatrixMessage> {
if event.get("type")?.as_str()? != "m.room.message"
|| event.pointer("/content/msgtype")?.as_str()? != "m.text"
{
return None;
}
let body = event.pointer("/content/body")?.as_str()?;
let body = strip_reply_fallback(body);
let body = truncate(&body, maximum_characters as usize);
Some(MatrixMessage {
event_id: event.get("event_id")?.as_str()?.to_owned(),
room_id: room_id.to_owned(),
sender_id: event.get("sender")?.as_str()?.to_owned(),
timestamp_ms: event
.get("origin_server_ts")
.and_then(Value::as_u64)
.unwrap_or_default(),
body,
reply_to_event_id: event
.pointer("/content/m.relates_to/m.in_reply_to/event_id")
.and_then(Value::as_str)
.map(str::to_owned),
})
}
fn strip_reply_fallback(body: &str) -> String {
if body.starts_with('>') {
body.split_once("\n\n")
.map_or_else(|| body.to_owned(), |(_, reply)| reply.to_owned())
} else {
body.to_owned()
}
}
fn truncate(value: &str, maximum_characters: usize) -> String {
let mut output: String = value.chars().take(maximum_characters).collect();
if value.chars().count() > maximum_characters {
output.push_str("… [truncated]");
}
output
}
fn escape_attribute(value: &str) -> String {
value
.replace('&', "&")
.replace('"', """)
.replace('<', "<")
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{buildContext, renderContext};
use crate::config::ContextConfig;
fn event(event_id: &str, body: &str) -> serde_json::Value {
json!({
"event_id": event_id,
"sender": "@human:example.test",
"type": "m.room.message",
"origin_server_ts": 1,
"content": {"msgtype": "m.text", "body": body}
})
}
#[test]
fn keeps_chronological_bounded_context_and_strips_reply_fallback() {
let limits = ContextConfig {
max_events: 2,
max_characters: 100,
max_event_characters: 100,
max_reply_depth: 0,
};
let messages = buildContext(
"!room:example.test",
&event("$invoke", "> quote\n\nanswer"),
vec![event("$newer", "newer"), event("$older", "older")],
&limits,
);
assert_eq!(
messages
.iter()
.map(|message| message.event_id.as_str())
.collect::<Vec<_>>(),
vec!["$newer", "$invoke"]
);
assert_eq!(messages[1].body, "answer");
assert!(renderContext(&messages).contains("author_id=\"@human:example.test\""));
}
}