1use crate::llm::actions::{
5 client_trait::{Client, ClientActionResult},
6 protocol_trait::Protocol,
7 ActionDefinition, Parameter, ParameterDefinition, StartupExamples,
8};
9use crate::protocol::{log_template::LogTemplate, ConnectContext, EventType};
10use crate::state::app_state::AppState;
11use anyhow::{bail, ensure, Context, Result};
12use serde_json::{json, Value};
13use std::sync::LazyLock;
14
15#[derive(Default)]
16pub struct MatrixClientProtocol;
17impl MatrixClientProtocol {
18 pub fn new() -> Self {
19 Self
20 }
21}
22
23fn p(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
24 Parameter {
25 name: name.into(),
26 type_hint: type_hint.into(),
27 description: description.into(),
28 required,
29 }
30}
31
32fn action(
33 name: &str,
34 description: &str,
35 parameters: Vec<Parameter>,
36 example: Value,
37) -> ActionDefinition {
38 ActionDefinition {
39 name: name.into(),
40 description: description.into(),
41 parameters,
42 example,
43 log_template: Some(LogTemplate::new().with_info(format!("-> Matrix {name}"))),
44 }
45}
46
47pub fn actions() -> Vec<ActionDefinition> {
48 vec![
49 action(
50 "matrix_create_room",
51 "Create a room and optionally invite users into it.",
52 vec![
53 p("name", "string", "The room's name", false),
54 p("topic", "string", "The room's topic", false),
55 p(
56 "invite",
57 "array",
58 "Full user ids to invite, e.g. [\"@bob:example.org\"]",
59 false,
60 ),
61 ],
62 json!({"type": "matrix_create_room", "name": "standup", "invite": ["@bob:localhost"]}),
63 ),
64 action(
65 "matrix_join",
66 "Join a room (accepting an invite), by id or alias.",
67 vec![p(
68 "room",
69 "string",
70 "Room id (!abc:server) or alias (#name:server)",
71 true,
72 )],
73 json!({"type": "matrix_join", "room": "!abc:localhost"}),
74 ),
75 action(
76 "matrix_send",
77 "Send a message into a room you are in.",
78 vec![
79 p(
80 "room_id",
81 "string",
82 "The room id, e.g. !abc:localhost",
83 true,
84 ),
85 p("body", "string", "Text of an m.text message", false),
86 p(
87 "msgtype",
88 "string",
89 "m.text (default), m.notice or m.emote",
90 false,
91 ),
92 p(
93 "content",
94 "object",
95 "Full event content instead of body/msgtype",
96 false,
97 ),
98 p(
99 "event_type",
100 "string",
101 "Event type (default m.room.message)",
102 false,
103 ),
104 ],
105 json!({"type": "matrix_send", "room_id": "!abc:localhost", "body": "hello"}),
106 ),
107 action(
108 "matrix_invite",
109 "Invite a user into a room you are in.",
110 vec![
111 p(
112 "room_id",
113 "string",
114 "The room id, e.g. !abc:localhost",
115 true,
116 ),
117 p("user_id", "string", "Full user id to invite", true),
118 ],
119 json!({"type": "matrix_invite", "room_id": "!abc:localhost", "user_id": "@bob:localhost"}),
120 ),
121 action(
122 "matrix_leave",
123 "Leave a room (or decline an invite).",
124 vec![p(
125 "room_id",
126 "string",
127 "The room id, e.g. !abc:localhost",
128 true,
129 )],
130 json!({"type": "matrix_leave", "room_id": "!abc:localhost"}),
131 ),
132 action(
133 "matrix_messages",
134 "Read a room's most recent events.",
135 vec![
136 p(
137 "room_id",
138 "string",
139 "The room id, e.g. !abc:localhost",
140 true,
141 ),
142 p(
143 "limit",
144 "number",
145 "How many events, newest first (default 10, at most 100)",
146 false,
147 ),
148 ],
149 json!({"type": "matrix_messages", "room_id": "!abc:localhost", "limit": 10}),
150 ),
151 action(
152 "disconnect",
153 "Log out and end this session.",
154 vec![],
155 json!({"type": "disconnect"}),
156 ),
157 ]
158}
159
160fn event(id: &str, description: &str, params: Vec<Parameter>) -> EventType {
161 EventType::new(
162 id,
163 description,
164 json!({"type": "matrix_send", "room_id": "!abc:localhost", "body": "hello"}),
165 )
166 .with_parameters(params)
167 .with_actions(actions())
168}
169
170pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
171 event(
172 "matrix_connected",
173 "Logged in; the client now follows /sync. Events from before this moment are not replayed.",
174 vec![
175 p("user_id", "string", "Who the client is logged in as", true),
176 p("device_id", "string", "The device id it logged in as", true),
177 p("joined_rooms", "array", "Rooms it is already in", true),
178 ],
179 )
180});
181
182pub static MESSAGE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
183 event(
184 "matrix_message",
185 "Someone else posted an event in a room the client is in.",
186 vec![
187 p("room_id", "string", "The room id it happened in", true),
188 p("sender", "string", "The full user id of who sent it", true),
189 p("event_type", "string", "e.g. m.room.message", true),
190 p(
191 "content",
192 "object",
193 "Its content, e.g. {msgtype, body}",
194 true,
195 ),
196 p("event_id", "string", "The event id, e.g. $abc", true),
197 ],
198 )
199});
200
201pub static INVITE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
202 event(
203 "matrix_invite",
204 "The client was invited into a room; matrix_join accepts, matrix_leave declines.",
205 vec![
206 p("room_id", "string", "The room id it happened in", true),
207 p("sender", "string", "The full user id of who invited", false),
208 p("room_name", "string", "The room's name, if shown", false),
209 ],
210 )
211});
212
213pub static MEMBER_EVENT: LazyLock<EventType> = LazyLock::new(|| {
214 event(
215 "matrix_member",
216 "Another user's membership in a room changed (joined, left, was invited).",
217 vec![
218 p("room_id", "string", "The room id it happened in", true),
219 p("user_id", "string", "Whose membership", true),
220 p("membership", "string", "join, leave, invite or ban", true),
221 ],
222 )
223});
224
225pub static RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
226 event(
227 "matrix_response",
228 "The homeserver answered an action (create_room gives room_id; messages gives events). A send that succeeds raises no event; one that fails does.",
229 vec![
230 p("operation", "string", "The action that was performed", true),
231 p("status", "number", "The HTTP status the homeserver answered with", true),
232 p("result", "object", "The answer: {room_id}, {events}, …", true),
233 p("errcode", "string", "Matrix error code when it failed", false),
234 p("error", "string", "The server's error text when it failed", false),
235 ],
236 )
237});
238
239pub fn request(v: &Value, txn: &str) -> Result<(&'static str, String, Option<Value>)> {
242 let enc = |s: &str| urlencoding::encode(s).into_owned();
243 let room_id = || -> Result<String> {
244 let r = v["room_id"].as_str().context("room_id required")?;
245 ensure!(
246 r.starts_with('!'),
247 "room_id must be a room id such as !abc:server"
248 );
249 Ok(enc(r))
250 };
251 Ok(match v["type"].as_str().unwrap_or_default() {
252 "matrix_create_room" => {
253 let mut body = json!({"preset": "private_chat"});
254 if let Some(n) = v["name"].as_str() {
255 body["name"] = json!(n);
256 }
257 if let Some(t) = v["topic"].as_str() {
258 body["topic"] = json!(t);
259 }
260 if !v["invite"].is_null() {
261 let users: Vec<&str> = v["invite"]
262 .as_array()
263 .context("invite must be an array of user ids")?
264 .iter()
265 .map(|u| u.as_str().filter(|u| u.starts_with('@') && u.contains(':')))
266 .collect::<Option<_>>()
267 .context("invite entries must be full user ids such as @bob:server")?;
268 body["invite"] = json!(users);
269 }
270 ("POST", "createRoom".into(), Some(body))
271 }
272 "matrix_join" => {
273 let r = v["room"].as_str().context("room required")?;
274 ensure!(
275 r.starts_with('!') || r.starts_with('#'),
276 "room must be a room id or alias"
277 );
278 ("POST", format!("join/{}", enc(r)), Some(json!({})))
279 }
280 "matrix_send" => {
281 let content = match (
282 v.get("content").filter(|c| !c.is_null()),
283 v["body"].as_str(),
284 ) {
285 (Some(c), _) => {
286 ensure!(c.is_object(), "content must be an object");
287 c.clone()
288 }
289 (None, Some(b)) => {
290 let msgtype = v["msgtype"].as_str().unwrap_or("m.text");
291 ensure!(
292 matches!(msgtype, "m.text" | "m.notice" | "m.emote"),
293 "msgtype must be m.text, m.notice or m.emote"
294 );
295 json!({"msgtype": msgtype, "body": b})
296 }
297 (None, None) => bail!("matrix_send needs body or content"),
298 };
299 let kind = v["event_type"].as_str().unwrap_or("m.room.message");
300 ensure!(
301 !kind.is_empty() && kind.len() <= 255,
302 "event_type is invalid"
303 );
304 (
305 "PUT",
306 format!("rooms/{}/send/{}/{}", room_id()?, enc(kind), enc(txn)),
307 Some(content),
308 )
309 }
310 "matrix_invite" => {
311 let u = v["user_id"].as_str().context("user_id required")?;
312 ensure!(
313 u.starts_with('@') && u.contains(':'),
314 "user_id must be a full user id"
315 );
316 (
317 "POST",
318 format!("rooms/{}/invite", room_id()?),
319 Some(json!({"user_id": u})),
320 )
321 }
322 "matrix_leave" => (
323 "POST",
324 format!("rooms/{}/leave", room_id()?),
325 Some(json!({})),
326 ),
327 "matrix_messages" => {
328 let limit = v["limit"].as_u64().unwrap_or(10).clamp(1, 100);
329 (
330 "GET",
331 format!("rooms/{}/messages?dir=b&limit={limit}", room_id()?),
332 None,
333 )
334 }
335 t => bail!("Unknown Matrix client action {t:?}"),
336 })
337}
338
339impl Protocol for MatrixClientProtocol {
340 fn protocol_name(&self) -> &'static str {
341 "Matrix"
342 }
343 fn stack_name(&self) -> &'static str {
344 "ETH>IP>TCP>HTTP>Matrix"
345 }
346 fn keywords(&self) -> Vec<&'static str> {
347 vec!["matrix", "matrix client", "synapse", "element", "chat bot"]
348 }
349 fn description(&self) -> &'static str {
350 "Matrix client (client-server API v3): logs in with a password, follows /sync, and creates, joins and talks in rooms"
351 }
352 fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
353 actions()
354 }
355 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
356 vec![]
357 }
358 fn get_event_types(&self) -> Vec<EventType> {
359 vec![
360 CONNECTED_EVENT.clone(),
361 MESSAGE_EVENT.clone(),
362 INVITE_EVENT.clone(),
363 MEMBER_EVENT.clone(),
364 RESPONSE_EVENT.clone(),
365 ]
366 }
367 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
368 vec![
369 ParameterDefinition {
370 name: "user".into(),
371 type_hint: "string".into(),
372 description: "The account to log in as: a localpart (alice) or a full user id"
373 .into(),
374 required: true,
375 example: json!("alice"),
376 default: None,
377 },
378 ParameterDefinition {
379 name: "password".into(),
380 type_hint: "string".into(),
381 description: "That account's password (m.login.password)".into(),
382 required: true,
383 example: json!("wonderland"),
384 default: None,
385 },
386 ParameterDefinition {
387 name: "device_id".into(),
388 type_hint: "string".into(),
389 description: "Device id to log in as (default: the server assigns one)".into(),
390 required: false,
391 example: json!("NETGET"),
392 default: None,
393 },
394 ]
395 }
396 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
397 use crate::protocol::metadata::*;
398 ProtocolMetadataV2::builder()
399 .state(DevelopmentState::Experimental)
400 .privilege_requirement(PrivilegeRequirement::None)
401 .implementation("HTTP/1.1 via hyper, one connection per request: v3 login (m.login.password), long-polling /sync (25 s) raising messages, invites and membership changes, createRoom, join, invite, leave, rooms/{id}/send with transaction ids, rooms/{id}/messages, logout")
402 .llm_control("Which rooms to create, join or leave, whom to invite, what to say, and how to answer every message and invite")
403 .e2e_testing("tests/client/matrix: Synapse (the reference homeserver), with a matrix-nio user as the other participant reading what NetGet said")
404 .notes("No end-to-end encryption (encrypted rooms' messages arrive as m.room.encrypted and cannot be read), no media, no registration. Events from before login are not replayed. Answers are capped at 4 MiB. A handler chain stops after 8 follow-ups.")
405 .build()
406 }
407 fn example_prompt(&self) -> &'static str {
408 "Log into the Matrix homeserver at 127.0.0.1:8008 as alice and answer every message in the rooms you are invited to"
409 }
410 fn get_startup_examples(&self) -> StartupExamples {
411 let llm = json!({"type":"open_client","protocol":"matrix","remote_addr":"127.0.0.1:8008",
412 "startup_params":{"user":"alice","password":"wonderland"},
413 "instruction":"Join every room you are invited to and answer 'pong' to every 'ping'"});
414 let mut static_example = llm.clone();
415 static_example["event_handlers"] = json!([{"event_pattern":"matrix_connected","handler":{"type":"static","actions":[{"type":"matrix_create_room","name":"netget","invite":["@bob:localhost"]}]}}]);
416 let mut scripted = llm.clone();
417 scripted["event_handlers"] = json!([{"event_pattern":"*","handler":{"type":"script","language":"python","code":"import json,sys\ni=json.load(sys.stdin); t=i['event_type_id']; e=i['event']\na=[]\nif t=='matrix_invite': a=[{'type':'matrix_join','room':e['room_id']}]\nelif t=='matrix_message' and e['content'].get('body')=='ping': a=[{'type':'matrix_send','room_id':e['room_id'],'body':'pong'}]\nprint(json.dumps({'actions':a}))"}}]);
418 StartupExamples::new(llm, scripted, static_example)
419 }
420 fn group_name(&self) -> &'static str {
421 "Application"
422 }
423}
424
425impl Client for MatrixClientProtocol {
426 fn connect(
427 &self,
428 ctx: ConnectContext,
429 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
430 {
431 Box::pin(super::connect(ctx))
432 }
433 fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
434 let name = v["type"].as_str().unwrap_or_default().to_string();
435 if name == "disconnect" {
436 return Ok(ClientActionResult::Disconnect);
437 }
438 request(&v, "0")?;
439 Ok(ClientActionResult::Custom { name, data: v })
440 }
441}