Skip to main content

netget/server/matrix/
actions.rs

1//! What the model decides as a Matrix homeserver: who may log in (when no `user_passwords` table is
2//! given), whether a room may be created or joined, whether a message is accepted — and
3//! what it says back, as its own user, into any room. Rust owns the client-server API's
4//! shapes, tokens, room membership and `/sync` delivery.
5use crate::llm::actions::{
6    protocol_trait::{ActionResult, Protocol, Server},
7    ActionDefinition, Parameter, ParameterDefinition, StartupExamples,
8};
9use crate::protocol::{log_template::LogTemplate, EventType, SpawnContext};
10use crate::state::app_state::AppState;
11use anyhow::{bail, Result};
12use serde_json::{json, Value};
13use std::sync::LazyLock;
14
15pub const ACCEPT: &str = "matrix_accept";
16pub const REJECT: &str = "matrix_reject";
17pub const SEND: &str = "matrix_send";
18/// The server name in user and room ids when the operator names none.
19pub const DEFAULT_SERVER_NAME: &str = "localhost";
20/// The localpart of the model's own user.
21pub const DEFAULT_BOT_USER: &str = "netget";
22/// A request body past this is refused (Synapse's default `max_upload_size` is far larger,
23/// but nothing the client-server API sends here is).
24pub const MAX_BODY_BYTES: usize = 1024 * 1024;
25
26#[derive(Default, Clone)]
27pub struct MatrixProtocol;
28
29impl MatrixProtocol {
30    pub fn new() -> Self {
31        Self
32    }
33}
34
35fn p(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
36    Parameter {
37        name: name.into(),
38        type_hint: type_hint.into(),
39        description: description.into(),
40        required,
41    }
42}
43
44fn action(
45    name: &str,
46    description: &str,
47    parameters: Vec<Parameter>,
48    example: Value,
49    info: &str,
50) -> ActionDefinition {
51    ActionDefinition {
52        name: name.into(),
53        description: description.into(),
54        parameters,
55        example,
56        log_template: Some(LogTemplate::new().with_info(info)),
57    }
58}
59
60pub fn accept_action() -> ActionDefinition {
61    action(
62        ACCEPT,
63        "Allow the request (the login, the room, the join, the message).",
64        vec![],
65        json!({"type": ACCEPT}),
66        "-> Matrix accept",
67    )
68}
69
70pub fn reject_action() -> ActionDefinition {
71    action(
72        REJECT,
73        "Refuse the request with a Matrix error, e.g. M_FORBIDDEN.",
74        vec![
75            p(
76                "errcode",
77                "string",
78                "Matrix error code (default M_FORBIDDEN)",
79                false,
80            ),
81            p(
82                "error",
83                "string",
84                "Human-readable reason shown to the client",
85                false,
86            ),
87        ],
88        json!({"type": REJECT, "errcode": "M_FORBIDDEN", "error": "This room is closed"}),
89        "-> Matrix reject {errcode}",
90    )
91}
92
93pub fn send_action() -> ActionDefinition {
94    action(
95        SEND,
96        "Post a message into a room as NetGet's own user (who joins the room if needed). Implies accepting the request being answered.",
97        vec![
98            p(
99                "room_id",
100                "string",
101                "Room to post in (default: the room of the event being answered)",
102                false,
103            ),
104            p(
105                "body",
106                "string",
107                "Text of an m.text message",
108                false,
109            ),
110            p(
111                "msgtype",
112                "string",
113                "Message type for body: m.text (default), m.notice or m.emote",
114                false,
115            ),
116            p(
117                "content",
118                "object",
119                "Full event content instead of body/msgtype",
120                false,
121            ),
122            p(
123                "event_type",
124                "string",
125                "Event type (default m.room.message)",
126                false,
127            ),
128        ],
129        json!({"type": SEND, "body": "Welcome to the room"}),
130        "-> Matrix send {body}",
131    )
132}
133
134fn ev(id: &str, description: &str, params: Vec<Parameter>, send: bool) -> EventType {
135    let mut actions = vec![accept_action(), reject_action()];
136    if send {
137        actions.push(send_action());
138    }
139    EventType::new(id, description, json!({"type": ACCEPT}))
140        .with_parameters(params)
141        .with_actions(actions)
142}
143
144pub static LOGIN_EVENT: LazyLock<EventType> = LazyLock::new(|| {
145    ev(
146        "matrix_login",
147        "A client logs in with a password (only asked when no user_passwords table was configured; the password is not shown). Accept or reject.",
148        vec![
149            p("user_id", "string", "The full Matrix user id", true),
150            p("device_id", "string", "The device the client logs in as", true),
151        ],
152        false,
153    )
154});
155
156pub static CREATE_ROOM_EVENT: LazyLock<EventType> = LazyLock::new(|| {
157    ev(
158        "matrix_create_room",
159        "A user asks to create a room. Accept (optionally posting a greeting with matrix_send) or reject.",
160        vec![
161            p("user_id", "string", "The full user id of who asks", true),
162            p("name", "string", "The requested room name, if any", false),
163            p("invite", "array", "Users the creator invites", true),
164        ],
165        true,
166    )
167});
168
169pub static JOIN_EVENT: LazyLock<EventType> = LazyLock::new(|| {
170    ev(
171        "matrix_join",
172        "A user asks to join a room. Accept or reject.",
173        vec![
174            p("user_id", "string", "The full user id of who asks", true),
175            p("room_id", "string", "The room id it happened in", true),
176            p("room_name", "string", "The room's name, if any", false),
177            p(
178                "invited",
179                "boolean",
180                "Whether the user had been invited",
181                true,
182            ),
183        ],
184        true,
185    )
186});
187
188pub static MESSAGE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
189    ev(
190        "matrix_room_message",
191        "A user sends an event into a room. Accept it (it is delivered to the room), reply with matrix_send (which also accepts it), or reject it (nobody sees it).",
192        vec![
193            p("room_id", "string", "The room id it happened in", true),
194            p("room_name", "string", "The room's name, if any", false),
195            p("sender", "string", "The full user id of who sent it", true),
196            p("event_type", "string", "e.g. m.room.message", true),
197            p("content", "object", "The event content, e.g. {msgtype, body}", true),
198            p("members", "array", "The room's members", true),
199        ],
200        true,
201    )
202});
203
204pub fn check(v: &Value) -> Result<()> {
205    let s = |k: &str| v.get(k).filter(|x| !x.is_null());
206    match v["type"].as_str().unwrap_or_default() {
207        ACCEPT => Ok(()),
208        REJECT => {
209            if let Some(c) = s("errcode") {
210                let c = c.as_str().unwrap_or_default();
211                if !c.starts_with("M_") || c.len() > 64 {
212                    bail!("errcode must be a Matrix error code such as M_FORBIDDEN");
213                }
214            }
215            Ok(())
216        }
217        SEND => {
218            match (s("body"), s("content")) {
219                (None, None) => bail!("matrix_send needs body or content"),
220                (_, Some(c)) if !c.is_object() => bail!("content must be an object"),
221                (Some(b), _) if !b.is_string() => bail!("body must be a string"),
222                _ => {}
223            }
224            if let Some(m) = s("msgtype").and_then(Value::as_str) {
225                if !matches!(m, "m.text" | "m.notice" | "m.emote") {
226                    bail!("msgtype must be m.text, m.notice or m.emote");
227                }
228            }
229            if let Some(r) = s("room_id") {
230                if !r.as_str().is_some_and(|r| r.starts_with('!')) {
231                    bail!("room_id must be a room id such as !abc:localhost");
232                }
233            }
234            Ok(())
235        }
236        other => bail!("Unknown Matrix action {other:?}"),
237    }
238}
239
240/// The event content a `matrix_send` posts.
241pub fn send_content(v: &Value) -> Value {
242    if let Some(c) = v.get("content").filter(|c| c.is_object()) {
243        return c.clone();
244    }
245    json!({
246        "msgtype": v["msgtype"].as_str().unwrap_or("m.text"),
247        "body": v["body"].as_str().unwrap_or_default(),
248    })
249}
250
251impl Protocol for MatrixProtocol {
252    fn protocol_name(&self) -> &'static str {
253        "Matrix"
254    }
255    fn stack_name(&self) -> &'static str {
256        "ETH>IP>TCP>HTTP>Matrix"
257    }
258    fn keywords(&self) -> Vec<&'static str> {
259        vec!["matrix", "homeserver", "synapse", "element", "chat", "8008"]
260    }
261    fn description(&self) -> &'static str {
262        "Matrix homeserver (client-server API v3): password login, rooms with invites and joins, messages delivered through long-polling /sync; the model gates logins, rooms and messages and talks in rooms as its own user"
263    }
264    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
265        Vec::new()
266    }
267    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
268        vec![accept_action(), reject_action(), send_action()]
269    }
270    fn get_event_types(&self) -> Vec<EventType> {
271        vec![
272            LOGIN_EVENT.clone(),
273            CREATE_ROOM_EVENT.clone(),
274            JOIN_EVENT.clone(),
275            MESSAGE_EVENT.clone(),
276        ]
277    }
278    fn default_binding(&self) -> Option<crate::protocol::BindingDefaults> {
279        Some(crate::protocol::BindingDefaults::port_based("127.0.0.1", 0))
280    }
281    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
282        vec![
283            ParameterDefinition {
284                name: "server_name".into(),
285                type_hint: "string".into(),
286                description: "The homeserver's name, the part after ':' in user and room ids".into(),
287                required: false,
288                example: json!("example.org"),
289                default: Some(json!(DEFAULT_SERVER_NAME)),
290            },
291            ParameterDefinition {
292                name: "user_passwords".into(),
293                type_hint: "object".into(),
294                description: "Accounts as {localpart: password}; when given, Rust checks passwords and the model is not asked about logins".into(),
295                required: false,
296                example: json!({"alice": "wonderland"}),
297                default: None,
298            },
299            ParameterDefinition {
300                name: "bot_user".into(),
301                type_hint: "string".into(),
302                description: "Localpart of the user the model speaks as in rooms".into(),
303                required: false,
304                example: json!("assistant"),
305                default: Some(json!(DEFAULT_BOT_USER)),
306            },
307        ]
308    }
309    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
310        use crate::protocol::metadata::*;
311        ProtocolMetadataV2::builder()
312            .state(DevelopmentState::Experimental)
313            .privilege_requirement(PrivilegeRequirement::None)
314            .well_known_port(8008)
315            .implementation("HTTP/1.1 via hyper (src/server/matrix): /_matrix/client/versions, v3 login (m.login.password), logout, account/whoami, createRoom (name, invite), join and rooms/{id}/join, rooms/{id}/send (with transaction-id idempotence), rooms/{id}/messages, joined_rooms, joined_members, user filters, and /sync with since tokens, invites and long polling (timeout capped at 30 s); tokens, membership and per-user delivery queues in src/server/matrix/hub.rs")
316            .llm_control("Logins (unless user_passwords is given), room creation, joins and every message: accept, reject with a Matrix errcode, or answer in the room as its own user")
317            .e2e_testing("tests/server/matrix: matrix-nio (an independent Python client) logs in, creates a room inviting a second nio client that joins, sends, and receives the model's reply through /sync; refusals and bounds over raw HTTP")
318            .notes("No federation, no end-to-end encryption (keys endpoints are 404 M_UNRECOGNIZED), no media repository, no registration, no presence or typing. Rooms are state the server holds while it runs; room history keeps the last 200 events and each user's undelivered queue 1000. A handler failure is a 500 M_UNKNOWN with a generic message; the message it was asked about is not delivered.")
319            .request_only("Every response answers an HTTP request")
320            .answers_on_failure()
321            .max_inbound_bytes(MAX_BODY_BYTES)
322            .build()
323    }
324    fn example_prompt(&self) -> &'static str {
325        "Matrix homeserver on port 8008 where the bot answers every message in the room"
326    }
327    fn get_startup_examples(&self) -> StartupExamples {
328        let base = json!({"type":"open_server","base_stack":"matrix","port":0,
329            "startup_params":{"user_passwords":{"alice":"wonderland","bob":"builder"}},
330            "instruction":"Accept rooms and joins; answer every m.text message with matrix_send, replying 'echo: ' plus its body"});
331        let mut scripted = base.clone();
332        scripted["event_handlers"] = json!([{"event_pattern":"matrix_room_message","handler":{"type":"script","language":"python",
333            "code":"import json,sys\ne=json.load(sys.stdin)['event']\nb=e['content'].get('body','')\nprint(json.dumps({'actions':[{'type':'matrix_send','body':'echo: '+b}] if e['sender'].startswith('@alice') else [{'type':'matrix_accept'}]}))"}},
334            {"event_pattern":"*","handler":{"type":"static","actions":[{"type":ACCEPT}]}}]);
335        let mut static_example = base.clone();
336        static_example["event_handlers"] =
337            json!([{"event_pattern":"*","handler":{"type":"static","actions":[{"type":ACCEPT}]}}]);
338        StartupExamples::new(base, scripted, static_example)
339    }
340    fn group_name(&self) -> &'static str {
341        "Application"
342    }
343}
344
345impl Server for MatrixProtocol {
346    fn spawn(
347        &self,
348        ctx: SpawnContext,
349    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
350    {
351        Box::pin(super::spawn(ctx))
352    }
353    fn execute_action(&self, action: Value) -> Result<ActionResult> {
354        check(&action)?;
355        let name = action["type"].as_str().unwrap_or_default().to_string();
356        Ok(ActionResult::Custom { name, data: action })
357    }
358}