1#![doc = include_str!("../README.md")]
11#![warn(missing_docs)]
12
13use serde_json::{Map, Value, json};
14
15use mxblocks::Rich;
16
17pub const RICH_SEND: &str = "rich_send";
19pub const RICH_EDIT: &str = "rich_edit";
21pub const MAX_FALLBACK: usize = 20000;
23pub const MAX_ROOM: usize = 300;
25
26pub const BLOCK_GUIDE: &str = "Pick the block for the answer: weather for weather questions; \
29table for comparing options or listing rows; place for one venue or address; event for a \
30date/time thing (meeting, flight, booking); countdown for a deadline; chart for a trend or \
31numbers over time; card/cards for one or a few products, listings or orders; sources for \
32links you used; code/diff for code; progress for multi-step work; details for long \
33optional text; ticker for prices; tracker for live order/ride/parcel status (send it once \
34with rich_send, then rich_edit it as the status changes, about every 30-60 s, ending with \
35state done); math for equations; gallery for 2+ images (mxc); file for one upload; form \
36for several choices at once; buttons/chips for a quick decision or quick replies. Numbers \
37are whole numbers or decimal strings (\"price\":\"62.99\"): Matrix refuses JSON floats, so a \
38float is sent as its decimal string.";
39
40pub 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)."}"#;
42
43pub fn prompt_line() -> String {
46 format!(
47 "- rich_send, rich_edit: rich messages as you in this chat: blocks that Element Web \
48 draws (table, weather, place, event, chart, tracker, …) plus a `fallback` text that \
49 carries everything for other clients (Element on phones shows only the text). Use them \
50 instead of a plain answer when structure helps; then end your turn with `[no reply]` \
51 unless there's more to say. {BLOCK_GUIDE} rich_send returns the new message's event id; \
52 rich_edit replaces that message (only your own, at most one edit every 2.5 s). \
53 Example rich_send: {WEATHER_EXAMPLE}"
54 )
55}
56
57#[derive(Debug, Clone)]
59pub struct ToolSpec {
60 pub name: &'static str,
62 pub description: String,
64 pub schema: Value,
66}
67
68pub fn tool_specs(rooms: &str) -> Vec<ToolSpec> {
71 let payload = json!({"type": "object", "description":
72 "the mxblocks payload: {\"v\":1,\"blocks\":[…]} (up to 8 blocks; the mxblocks v1 \
73 catalogue, validated before sending)"});
74 let fallback = json!({"type": "string", "description":
75 "the message text (CommonMark, rendered like a normal message): it must carry \
76 everything, since only Element Web draws the blocks"});
77 let room = json!({"type": "string", "description": format!(
78 "where to post (default: this chat). {rooms}")});
79 let event = |d: &str| json!({"type": "string", "description": d});
80 vec![
81 ToolSpec {
82 name: RICH_SEND,
83 description: format!(
84 "Send a rich message as you: blocks (tables, weather, places, events, charts, \
85 trackers, …) drawn by Element Web, with a text fallback for everything else. \
86 Returns the new message's event id (keep it to rich_edit it later). {BLOCK_GUIDE} \
87 Example: {WEATHER_EXAMPLE}"
88 ),
89 schema: json!({"type": "object", "properties": {
90 "payload": payload, "fallback": fallback,
91 "reply_to": event("reply to this event id ($…), optional"),
92 "thread": event("post it in this thread (its root event id $…), optional"),
93 "room": room}, "required": ["payload", "fallback"],
94 "additionalProperties": false}),
95 },
96 ToolSpec {
97 name: RICH_EDIT,
98 description: "Replace one of your own earlier messages in place (an m.replace with \
99 the new blocks and text; pings nobody). Only your own messages, at \
100 most one edit per message every 2.5 s (a faster edit waits); a typical \
101 live update is every 30-60 s."
102 .to_owned(),
103 schema: json!({"type": "object", "properties": {
104 "event_id": event("the message to replace ($…, the one rich_send returned)"),
105 "payload": payload, "fallback": fallback, "room": room},
106 "required": ["event_id", "payload", "fallback"],
107 "additionalProperties": false}),
108 },
109 ]
110}
111
112#[derive(Debug, Clone, PartialEq)]
114pub struct RichCall {
115 pub payload: Rich,
117 pub fallback: String,
119 pub reply_to: Option<String>,
121 pub thread: Option<String>,
123 pub edit: Option<String>,
125 pub room: Option<String>,
127}
128
129pub fn valid_event_id(s: &str) -> bool {
132 s.len() <= 255
133 && s.strip_prefix('$')
134 .is_some_and(|r| !r.is_empty() && !r.chars().any(char::is_whitespace))
135}
136
137fn event_arg(a: &Map<String, Value>, key: &str) -> Result<Option<String>, String> {
138 match a.get(key) {
139 None | Some(Value::Null) => Ok(None),
140 Some(Value::String(s)) if valid_event_id(s.trim()) => Ok(Some(s.trim().to_owned())),
141 Some(_) => Err(format!("`{key}` must be an event id ($…)")),
142 }
143}
144
145pub fn is_rich_tool(name: &str) -> bool {
147 name == RICH_SEND || name == RICH_EDIT
148}
149
150pub fn plan(name: &str, args: &Value) -> Result<RichCall, String> {
155 let empty = Map::new();
156 let a = match args {
157 Value::Object(m) => m,
158 Value::Null => &empty,
159 _ => return Err("arguments must be an object".into()),
160 };
161 let edit = match name {
162 RICH_SEND => false,
163 RICH_EDIT => true,
164 _ => return Err(format!("no tool `{name}`")),
165 };
166 let keys: &[&str] = if edit {
167 &["event_id", "payload", "fallback", "room"]
168 } else {
169 &["payload", "fallback", "reply_to", "thread", "room"]
170 };
171 if let Some(k) = a.keys().find(|k| !keys.contains(&k.as_str())) {
172 return Err(format!(
173 "unknown argument `{}`",
174 k.chars().take(40).collect::<String>()
175 ));
176 }
177 let payload = match a.get("payload") {
178 Some(Value::String(s)) => Rich::parse(s)?,
179 Some(v @ Value::Object(_)) => Rich::parse(&v.to_string())?,
180 _ => return Err("`payload` (a {\"v\":1,\"blocks\":[…]} object) is required".into()),
181 };
182 let fallback = a
183 .get("fallback")
184 .and_then(Value::as_str)
185 .map(str::trim)
186 .unwrap_or_default();
187 if fallback.is_empty() && payload.find_math().is_none() {
188 return Err("`fallback` (the message text) is required".into());
189 }
190 if fallback.chars().count() > MAX_FALLBACK {
191 return Err(format!("`fallback` has at most {MAX_FALLBACK} characters"));
192 }
193 let room = match a.get("room") {
194 None | Some(Value::Null) => None,
195 Some(Value::String(s))
196 if !s.trim().is_empty() && s.len() <= MAX_ROOM && !s.contains(['\n', '\t']) =>
197 {
198 Some(s.trim().to_owned())
199 }
200 Some(_) => return Err("`room` is one line naming a room".into()),
201 };
202 let (reply_to, thread, edit) = if edit {
203 let e = event_arg(a, "event_id")?.ok_or("`event_id` ($…) is required")?;
204 (None, None, Some(e))
205 } else {
206 (event_arg(a, "reply_to")?, event_arg(a, "thread")?, None)
207 };
208 Ok(RichCall {
209 payload,
210 fallback: fallback.to_owned(),
211 reply_to,
212 thread,
213 edit,
214 room,
215 })
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 #[test]
223 fn specs() {
224 let t = tool_specs("`dm` or a session room id.");
225 assert_eq!(
226 t.iter().map(|t| t.name).collect::<Vec<_>>(),
227 [RICH_SEND, RICH_EDIT]
228 );
229 assert!(t[0].description.contains("weather for weather"));
230 assert!(
231 t[0].schema["properties"]["room"]["description"]
232 .as_str()
233 .unwrap()
234 .contains("`dm`")
235 );
236 let ex: Value = serde_json::from_str(WEATHER_EXAMPLE).unwrap();
238 plan(RICH_SEND, &ex).unwrap();
239 }
240
241 #[test]
242 fn floats_become_decimal_strings() {
243 let p = json!({"v": 1, "blocks": [{"type": "ticker", "symbol": "PHP/THB",
245 "price": 0.53396, "change": 0.0038, "series": [0.53, 1.0]}]});
246 for args in [
247 json!({"payload": p, "fallback": "x"}),
248 json!({"payload": p.to_string(), "fallback": "x"}),
249 ] {
250 let c = plan(RICH_SEND, &args).unwrap();
251 let v = serde_json::to_value(&c.payload).unwrap();
252 assert!(!mxblocks::has_float(&v), "{v}");
253 assert_eq!(v["blocks"][0]["price"], "0.53396");
254 assert_eq!(v["blocks"][0]["series"], json!(["0.53", 1]));
255 }
256 let big = json!({"v": 1, "blocks": [{"type": "ticker", "symbol": "X",
257 "price": 9_007_199_254_740_993_u64}]});
258 let e = plan(RICH_SEND, &json!({"payload": big, "fallback": "x"})).unwrap_err();
259 assert!(e.contains("blocks[0].price"), "{e}");
260 }
261
262 #[test]
263 fn calls() {
264 let p = json!({"v": 1, "blocks": [{"type": "table", "columns": ["a"], "rows": [["1"]]}]});
265 let c = plan(RICH_SEND, &json!({"payload": p, "fallback": " a: 1 "})).unwrap();
266 assert_eq!(c.fallback, "a: 1");
267 assert_eq!((c.reply_to, c.edit, c.room), (None, None, None));
268 let c = plan(
270 RICH_SEND,
271 &json!({"payload": p.to_string(), "fallback": "x", "reply_to": "$r", "room": "dm"}),
272 )
273 .unwrap();
274 assert_eq!(c.reply_to.as_deref(), Some("$r"));
275 assert_eq!(c.room.as_deref(), Some("dm"));
276 let c = plan(
277 RICH_SEND,
278 &json!({"payload": p.to_string(), "fallback": "x", "thread": "$t"}),
279 )
280 .unwrap();
281 assert_eq!(c.thread.as_deref(), Some("$t"));
282 assert_eq!(c.room, None);
283 let c = plan(
284 RICH_EDIT,
285 &json!({"event_id": "$e", "payload": p, "fallback": "x"}),
286 )
287 .unwrap();
288 assert_eq!(c.edit.as_deref(), Some("$e"));
289 let m = json!({"v": 1, "blocks": [{"type": "math", "tex": "x^2"}]});
291 assert!(plan(RICH_SEND, &json!({"payload": m})).is_ok());
292
293 for (name, args, why) in [
294 (RICH_SEND, json!({"fallback": "x"}), "payload"),
295 (RICH_SEND, json!({"payload": p}), "fallback"),
296 (
297 RICH_SEND,
298 json!({"payload": p, "fallback": "x", "event_id": "$e"}),
299 "unknown",
300 ),
301 (
302 RICH_EDIT,
303 json!({"payload": p, "fallback": "x"}),
304 "event_id",
305 ),
306 (
307 RICH_EDIT,
308 json!({"event_id": "e", "payload": p, "fallback": "x"}),
309 "event id",
310 ),
311 (
312 RICH_SEND,
313 json!({"payload": p, "fallback": "x", "reply_to": "$ x"}),
314 "event id",
315 ),
316 (
317 RICH_SEND,
318 json!({"payload": p, "fallback": "x", "room": "a\nb"}),
319 "room",
320 ),
321 (
322 RICH_SEND,
323 json!({"payload": {"v": 1, "blocks": [{"type": "weather"}]}, "fallback": "x"}),
324 "weather",
325 ),
326 (
327 RICH_SEND,
328 json!({"payload": p, "fallback": "x".repeat(MAX_FALLBACK + 1)}),
329 "at most",
330 ),
331 ("rich_other", json!({}), "no tool"),
332 ] {
333 let e = plan(name, &args).unwrap_err();
334 assert!(e.contains(why), "{why}: {e}");
335 }
336 assert!(plan(RICH_SEND, &json!([1])).is_err());
337 }
338}