#![doc = include_str!("../README.md")]
#![warn(missing_docs)]
use serde_json::{Map, Value, json};
use mxblocks::Rich;
pub const RICH_SEND: &str = "rich_send";
pub const RICH_EDIT: &str = "rich_edit";
pub const MAX_FALLBACK: usize = 20000;
pub const MAX_ROOM: usize = 300;
pub const BLOCK_GUIDE: &str = "Pick the block for the answer: weather for weather questions; \
table for comparing options or listing rows; place for one venue or address; event for a \
date/time thing (meeting, flight, booking); countdown for a deadline; chart for a trend or \
numbers over time; card/cards for one or a few products, listings or orders; sources for \
links you used; code/diff for code; progress for multi-step work; details for long \
optional text; ticker for prices; tracker for live order/ride/parcel status (send it once \
with rich_send, then rich_edit it as the status changes, about every 30-60 s, ending with \
state done); math for equations; gallery for 2+ images (mxc); file for one upload; form \
for several choices at once; buttons/chips for a quick decision or quick replies. Numbers \
are whole numbers or decimal strings (\"price\":\"62.99\"): Matrix refuses JSON floats, so a \
float is sent as its decimal string.";
pub const WEATHER_EXAMPLE: &str = r#"{"payload":{"v":1,"blocks":[{"type":"weather","location":"Lisbon","temp":24,"unit":"C","condition":"Partly cloudy","high":26,"low":17}]},"fallback":"Lisbon: 24°C, partly cloudy (26/17)."}"#;
pub fn prompt_line() -> String {
format!(
"- rich_send, rich_edit: rich messages as you in this chat: blocks that Element Web \
draws (table, weather, place, event, chart, tracker, …) plus a `fallback` text that \
carries everything for other clients (Element on phones shows only the text). Use them \
instead of a plain answer when structure helps; then end your turn with `[no reply]` \
unless there's more to say. {BLOCK_GUIDE} rich_send returns the new message's event id; \
rich_edit replaces that message (only your own, at most one edit every 2.5 s). \
Example rich_send: {WEATHER_EXAMPLE}"
)
}
#[derive(Debug, Clone)]
pub struct ToolSpec {
pub name: &'static str,
pub description: String,
pub schema: Value,
}
pub fn tool_specs(rooms: &str) -> Vec<ToolSpec> {
let payload = json!({"type": "object", "description":
"the mxblocks payload: {\"v\":1,\"blocks\":[…]} (up to 8 blocks; the mxblocks v1 \
catalogue, validated before sending)"});
let fallback = json!({"type": "string", "description":
"the message text (CommonMark, rendered like a normal message): it must carry \
everything, since only Element Web draws the blocks"});
let room = json!({"type": "string", "description": format!(
"where to post (default: this chat). {rooms}")});
let event = |d: &str| json!({"type": "string", "description": d});
vec![
ToolSpec {
name: RICH_SEND,
description: format!(
"Send a rich message as you: blocks (tables, weather, places, events, charts, \
trackers, …) drawn by Element Web, with a text fallback for everything else. \
Returns the new message's event id (keep it to rich_edit it later). {BLOCK_GUIDE} \
Example: {WEATHER_EXAMPLE}"
),
schema: json!({"type": "object", "properties": {
"payload": payload, "fallback": fallback,
"reply_to": event("reply to this event id ($…), optional"),
"thread": event("post it in this thread (its root event id $…), optional"),
"room": room}, "required": ["payload", "fallback"],
"additionalProperties": false}),
},
ToolSpec {
name: RICH_EDIT,
description: "Replace one of your own earlier messages in place (an m.replace with \
the new blocks and text; pings nobody). Only your own messages, at \
most one edit per message every 2.5 s (a faster edit waits); a typical \
live update is every 30-60 s."
.to_owned(),
schema: json!({"type": "object", "properties": {
"event_id": event("the message to replace ($…, the one rich_send returned)"),
"payload": payload, "fallback": fallback, "room": room},
"required": ["event_id", "payload", "fallback"],
"additionalProperties": false}),
},
]
}
#[derive(Debug, Clone, PartialEq)]
pub struct RichCall {
pub payload: Rich,
pub fallback: String,
pub reply_to: Option<String>,
pub thread: Option<String>,
pub edit: Option<String>,
pub room: Option<String>,
}
pub fn valid_event_id(s: &str) -> bool {
s.len() <= 255
&& s.strip_prefix('$')
.is_some_and(|r| !r.is_empty() && !r.chars().any(char::is_whitespace))
}
fn event_arg(a: &Map<String, Value>, key: &str) -> Result<Option<String>, String> {
match a.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) if valid_event_id(s.trim()) => Ok(Some(s.trim().to_owned())),
Some(_) => Err(format!("`{key}` must be an event id ($…)")),
}
}
pub fn is_rich_tool(name: &str) -> bool {
name == RICH_SEND || name == RICH_EDIT
}
pub fn plan(name: &str, args: &Value) -> Result<RichCall, String> {
let empty = Map::new();
let a = match args {
Value::Object(m) => m,
Value::Null => &empty,
_ => return Err("arguments must be an object".into()),
};
let edit = match name {
RICH_SEND => false,
RICH_EDIT => true,
_ => return Err(format!("no tool `{name}`")),
};
let keys: &[&str] = if edit {
&["event_id", "payload", "fallback", "room"]
} else {
&["payload", "fallback", "reply_to", "thread", "room"]
};
if let Some(k) = a.keys().find(|k| !keys.contains(&k.as_str())) {
return Err(format!(
"unknown argument `{}`",
k.chars().take(40).collect::<String>()
));
}
let payload = match a.get("payload") {
Some(Value::String(s)) => Rich::parse(s)?,
Some(v @ Value::Object(_)) => Rich::parse(&v.to_string())?,
_ => return Err("`payload` (a {\"v\":1,\"blocks\":[…]} object) is required".into()),
};
let fallback = a
.get("fallback")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
if fallback.is_empty() && payload.find_math().is_none() {
return Err("`fallback` (the message text) is required".into());
}
if fallback.chars().count() > MAX_FALLBACK {
return Err(format!("`fallback` has at most {MAX_FALLBACK} characters"));
}
let room = match a.get("room") {
None | Some(Value::Null) => None,
Some(Value::String(s))
if !s.trim().is_empty() && s.len() <= MAX_ROOM && !s.contains(['\n', '\t']) =>
{
Some(s.trim().to_owned())
}
Some(_) => return Err("`room` is one line naming a room".into()),
};
let (reply_to, thread, edit) = if edit {
let e = event_arg(a, "event_id")?.ok_or("`event_id` ($…) is required")?;
(None, None, Some(e))
} else {
(event_arg(a, "reply_to")?, event_arg(a, "thread")?, None)
};
Ok(RichCall {
payload,
fallback: fallback.to_owned(),
reply_to,
thread,
edit,
room,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn specs() {
let t = tool_specs("`dm` or a session room id.");
assert_eq!(
t.iter().map(|t| t.name).collect::<Vec<_>>(),
[RICH_SEND, RICH_EDIT]
);
assert!(t[0].description.contains("weather for weather"));
assert!(
t[0].schema["properties"]["room"]["description"]
.as_str()
.unwrap()
.contains("`dm`")
);
let ex: Value = serde_json::from_str(WEATHER_EXAMPLE).unwrap();
plan(RICH_SEND, &ex).unwrap();
}
#[test]
fn floats_become_decimal_strings() {
let p = json!({"v": 1, "blocks": [{"type": "ticker", "symbol": "PHP/THB",
"price": 0.53396, "change": 0.0038, "series": [0.53, 1.0]}]});
for args in [
json!({"payload": p, "fallback": "x"}),
json!({"payload": p.to_string(), "fallback": "x"}),
] {
let c = plan(RICH_SEND, &args).unwrap();
let v = serde_json::to_value(&c.payload).unwrap();
assert!(!mxblocks::has_float(&v), "{v}");
assert_eq!(v["blocks"][0]["price"], "0.53396");
assert_eq!(v["blocks"][0]["series"], json!(["0.53", 1]));
}
let big = json!({"v": 1, "blocks": [{"type": "ticker", "symbol": "X",
"price": 9_007_199_254_740_993_u64}]});
let e = plan(RICH_SEND, &json!({"payload": big, "fallback": "x"})).unwrap_err();
assert!(e.contains("blocks[0].price"), "{e}");
}
#[test]
fn calls() {
let p = json!({"v": 1, "blocks": [{"type": "table", "columns": ["a"], "rows": [["1"]]}]});
let c = plan(RICH_SEND, &json!({"payload": p, "fallback": " a: 1 "})).unwrap();
assert_eq!(c.fallback, "a: 1");
assert_eq!((c.reply_to, c.edit, c.room), (None, None, None));
let c = plan(
RICH_SEND,
&json!({"payload": p.to_string(), "fallback": "x", "reply_to": "$r", "room": "dm"}),
)
.unwrap();
assert_eq!(c.reply_to.as_deref(), Some("$r"));
assert_eq!(c.room.as_deref(), Some("dm"));
let c = plan(
RICH_SEND,
&json!({"payload": p.to_string(), "fallback": "x", "thread": "$t"}),
)
.unwrap();
assert_eq!(c.thread.as_deref(), Some("$t"));
assert_eq!(c.room, None);
let c = plan(
RICH_EDIT,
&json!({"event_id": "$e", "payload": p, "fallback": "x"}),
)
.unwrap();
assert_eq!(c.edit.as_deref(), Some("$e"));
let m = json!({"v": 1, "blocks": [{"type": "math", "tex": "x^2"}]});
assert!(plan(RICH_SEND, &json!({"payload": m})).is_ok());
for (name, args, why) in [
(RICH_SEND, json!({"fallback": "x"}), "payload"),
(RICH_SEND, json!({"payload": p}), "fallback"),
(
RICH_SEND,
json!({"payload": p, "fallback": "x", "event_id": "$e"}),
"unknown",
),
(
RICH_EDIT,
json!({"payload": p, "fallback": "x"}),
"event_id",
),
(
RICH_EDIT,
json!({"event_id": "e", "payload": p, "fallback": "x"}),
"event id",
),
(
RICH_SEND,
json!({"payload": p, "fallback": "x", "reply_to": "$ x"}),
"event id",
),
(
RICH_SEND,
json!({"payload": p, "fallback": "x", "room": "a\nb"}),
"room",
),
(
RICH_SEND,
json!({"payload": {"v": 1, "blocks": [{"type": "weather"}]}, "fallback": "x"}),
"weather",
),
(
RICH_SEND,
json!({"payload": p, "fallback": "x".repeat(MAX_FALLBACK + 1)}),
"at most",
),
("rich_other", json!({}), "no tool"),
] {
let e = plan(name, &args).unwrap_err();
assert!(e.contains(why), "{why}: {e}");
}
assert!(plan(RICH_SEND, &json!([1])).is_err());
}
}