1use 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";
18pub const DEFAULT_SERVER_NAME: &str = "localhost";
20pub const DEFAULT_BOT_USER: &str = "netget";
22pub 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
240pub 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}