//! Safe, bounded Matrix reply rendering for model-produced text.
use std::fmt::Write as _;
use serde_json::json;
use crate::{errors::MatrixError, tools::Source};
/// Renders a bounded answer as a Matrix rich reply payload.
pub fn renderReply(
invocation_event_id: &str,
invocation_sender_id: &str,
invocation_excerpt: &str,
markdown: &str,
maximum_characters: u32,
) -> Result<String, MatrixError> {
renderReplyWithSources(
invocation_event_id,
invocation_sender_id,
invocation_excerpt,
markdown,
&[],
maximum_characters,
)
}
/// Renders a bounded answer and host-validated research sources as a Matrix reply.
pub fn renderReplyWithSources(
invocation_event_id: &str,
invocation_sender_id: &str,
invocation_excerpt: &str,
markdown: &str,
sources: &[Source],
maximum_characters: u32,
) -> Result<String, MatrixError> {
let plain_markdown = append_sources(markdown, sources);
if plain_markdown.trim().is_empty()
|| plain_markdown.chars().count() > maximum_characters as usize
{
return Err(MatrixError::InvalidResponse(
"answer exceeded the configured response limit".to_owned(),
));
}
let excerpt = truncate(invocation_excerpt, 500);
let fallback = format!("> <{invocation_sender_id}> {excerpt}\n>\n{plain_markdown}");
let rendered_answer = format!("{}{}", render_markdown(markdown), render_sources_html(sources));
let formatted_body = format!(
"<mx-reply><blockquote><a href=\"https://matrix.to/#/{sender}\">{sender}</a><br>{excerpt}</blockquote></mx-reply>{answer}",
sender = escape_html(invocation_sender_id),
excerpt = escape_html(&excerpt),
answer = rendered_answer,
);
serde_json::to_string(&json!({
"msgtype": "m.text",
"body": fallback,
"format": "org.matrix.custom.html",
"formatted_body": formatted_body,
"m.relates_to": {"m.in_reply_to": {"event_id": invocation_event_id}},
}))
.map_err(|error| MatrixError::InvalidResponse(error.to_string()))
}
fn append_sources(markdown: &str, sources: &[Source]) -> String {
if sources.is_empty() {
return markdown.to_owned();
}
let mut answer = String::from(markdown);
answer.push_str("\n\nSources:");
for source in sources.iter().take(8) {
let _ = write!(answer, "\n- [{}]({})", source.title, source.url);
}
answer
}
fn render_sources_html(sources: &[Source]) -> String {
if sources.is_empty() {
return String::new();
}
let mut output = String::from("<p>Sources:</p><ul>");
for source in sources.iter().take(8) {
output.push_str("<li><a href=\"");
output.push_str(&escape_html(source.url.as_str()));
output.push_str("\">");
output.push_str(&escape_html(&source.title));
output.push_str("</a></li>");
}
output.push_str("</ul>");
output
}
fn render_markdown(markdown: &str) -> String {
let mut output = String::new();
let mut code_block = false;
let mut list_open = false;
for line in markdown.lines() {
if line.starts_with("```") {
if list_open {
output.push_str("</ul>");
list_open = false;
}
output.push_str(if code_block {
"</code></pre>"
} else {
"<pre><code>"
});
code_block = !code_block;
continue;
}
let escaped = escape_html(line);
if code_block {
output.push_str(&escaped);
output.push('\n');
continue;
}
if let Some(item) = escaped.strip_prefix("- ") {
if !list_open {
output.push_str("<ul>");
list_open = true;
}
output.push_str("<li>");
output.push_str(item);
output.push_str("</li>");
} else {
if list_open {
output.push_str("</ul>");
list_open = false;
}
if !escaped.is_empty() {
output.push_str("<p>");
output.push_str(&escaped);
output.push_str("</p>");
}
}
}
if list_open {
output.push_str("</ul>");
}
if code_block {
output.push_str("</code></pre>");
}
output
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn truncate(value: &str, maximum: usize) -> String {
let mut output: String = value.chars().take(maximum).collect();
if value.chars().count() > maximum {
output.push('…');
}
output
}
#[cfg(test)]
mod tests {
use url::Url;
use super::{renderReply, renderReplyWithSources};
use crate::tools::Source;
#[test]
fn escapes_untrusted_markdown_and_sets_the_reply_relation() {
let payload = renderReply(
"$event:example.test",
"@human:example.test",
"<script>",
"- <unsafe>",
100,
)
.unwrap();
assert!(payload.contains("m.in_reply_to"));
assert!(payload.contains("<unsafe>"));
assert!(payload.contains("<script>"));
}
#[test]
fn renders_host_validated_sources_as_html_links() {
let source = Source {
url: Url::parse("https://example.test/article").unwrap(),
title: "Article".to_owned(),
};
let payload = renderReplyWithSources(
"$event:example.test",
"@human:example.test",
"question",
"answer",
&[source],
500,
).unwrap();
assert!(payload.contains("href=\\\"https://example.test/article\\\""));
}
}