Skip to main content

netget/client/matrix/
actions.rs

1//! What the model does as a Matrix user: create rooms (inviting people), join, invite,
2//! leave, read a room's recent history and send messages. Logging in, `/sync` and
3//! transaction ids are Rust's.
4use 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
239/// The HTTP request an action makes (method, path under /_matrix/client/v3, body); `txn`
240/// numbers a send.
241pub 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}