1 module matrix; 2 import std.json; 3 import std.format : format; 4 import std..string; 5 import std.net.curl : HTTP, CurlCode, ThrowOnError; 6 import std.conv; 7 import std.range; 8 9 class MatrixClient 10 { 11 private: 12 static const string[string] NULL_PARAMS; 13 public: 14 uint transactionId; 15 string nextBatch; 16 17 string buildUrl(string endpoint, const string[string] params = NULL_PARAMS, 18 string apiVersion = "unstable", string section = "client") 19 { 20 string url = "%s/_matrix/%s/%s/%s".format(this.homeserver, section, apiVersion, endpoint); 21 char concat = '?'; 22 23 if (this.accessToken.length) 24 { 25 url ~= "%caccess_token=%s".format(concat, this.accessToken); 26 concat = '&'; 27 } 28 29 string paramString = this.makeParamString(params, concat); 30 if (paramString.length) 31 url ~= paramString; 32 33 return url; 34 } 35 36 string makeParamString(const string[string] params, char concat) 37 { 38 if (params.length == 0) 39 { 40 return ""; 41 } 42 string result = "%s".format(concat); 43 foreach (key, value; params) 44 { 45 result ~= "%s=%s&".format(key, value); 46 } 47 return result[0 .. $ - 1]; 48 } 49 50 string translateRoomId(string roomId) 51 { 52 return translate(roomId, ['#': "%23", ':': "%3A"]); 53 } 54 55 JSONValue makeHttpRequest(string method)(string url, 56 JSONValue data = JSONValue(), HTTP http = HTTP()) 57 { 58 http.url(url); 59 JSONValue returnbody; 60 string returnstr = ""; 61 62 static if (method == "GET") 63 http.method(HTTP.Method.get); 64 else static if (method == "POST") 65 http.method(HTTP.Method.post); 66 else static if (method == "PUT") 67 { 68 // Using the HTTP struct with PUT seems to hang, don't use it 69 http.method(HTTP.Method.put); 70 } 71 else static if (method == "DELETE") 72 http.method(HTTP.Method.del); 73 74 //import std.stdio; 75 //writeln(method ~ " " ~ url); 76 //writeln(data.toString); 77 78 if (!data.isNull) 79 http.postData(data.toString); 80 http.onReceive = (ubyte[] data) { 81 returnstr ~= cast(string) data; 82 return data.length; 83 }; 84 //http.verbose(true); 85 CurlCode c = http.perform(ThrowOnError.no); 86 //writeln(c); 87 //writeln(returnstr); 88 returnbody = parseJSON(returnstr); 89 if (c) 90 { 91 throw new MatrixException(c, returnbody); 92 } 93 return returnbody; 94 } 95 96 JSONValue get(string url) 97 { 98 return makeHttpRequest!("GET")(url); 99 } 100 101 JSONValue post(string url, JSONValue data = JSONValue()) 102 { 103 return makeHttpRequest!("POST")(url, data); 104 } 105 106 JSONValue put(string url, JSONValue data = JSONValue()) 107 { 108 // Using the HTTP struct with PUT seems to hang 109 // return makeHttpRequest!("PUT")(url, data); 110 111 // std.net.curl.put works fine 112 import std.net.curl : cput = put; 113 114 return parseJSON(cput(url, data.toString())); 115 } 116 117 string homeserver, userId, accessToken, deviceId; 118 bool useNotice = true; 119 120 string getTextMessageType() 121 { 122 return useNotice ? "m.notice" : "m.text"; 123 } 124 125 this(string homeserver = "https://matrix.org") 126 { 127 this.homeserver = homeserver; 128 // Check well known matrix 129 } 130 131 /// Log in to the matrix server using a username and password. 132 /// deviceId is optional, if none provided, server will generate it's own 133 /// If provided, server will invalidate the previous access token for this device 134 void passwordLogin(string user, string password, string device_id = null) 135 { 136 string url = buildUrl("login"); 137 JSONValue req = JSONValue(); 138 req["type"] = "m.login.password"; 139 req["user"] = user; 140 req["password"] = password; 141 if (device_id) 142 req["device_id"] = device_id; 143 144 JSONValue resp = post(url, req); 145 146 this.accessToken = resp["access_token"].str; 147 this.userId = resp["user_id"].str; 148 this.deviceId = resp["device_id"].str; 149 } 150 151 /// Get information about all devices for current user 152 MatrixDeviceInfo[] getDevices() 153 { 154 string url = buildUrl("devices"); 155 JSONValue ret = get(url); 156 157 MatrixDeviceInfo[] inf; 158 foreach (d; ret["devices"].array) 159 { 160 MatrixDeviceInfo i = new MatrixDeviceInfo(); 161 i.deviceId = d["device_id"].str; 162 if (!d["display_name"].isNull) 163 i.displayName = d["display_name"].str; 164 if (!d["last_seen_ip"].isNull) 165 i.lastSeenIP = d["last_seen_ip"].str; 166 if (!d["last_seen_ts"].isNull) 167 i.lastSeen = d["last_seen_ts"].integer; 168 169 inf ~= i; 170 } 171 172 return inf; 173 } 174 175 /// Get information for a single device by it's device id 176 MatrixDeviceInfo getDeviceInfo(string device_id) 177 { 178 string url = buildUrl("devices/%s".format(device_id)); 179 JSONValue ret = get(url); 180 181 MatrixDeviceInfo i = new MatrixDeviceInfo(); 182 i.deviceId = ret["device_id"].str; 183 if (!ret["display_name"].isNull) 184 i.displayName = ret["display_name"].str; 185 if (!ret["last_seen_ip"].isNull) 186 i.lastSeenIP = ret["last_seen_ip"].str; 187 if (!ret["last_seen_ts"].isNull) 188 i.lastSeen = ret["last_seen_ts"].integer; 189 190 return i; 191 } 192 193 /// Updates the display name for a device 194 /// device_id is optional, if null, current device ID will be used 195 void setDeviceName(string name, string device_id = null) 196 { 197 if (!device_id) 198 device_id = deviceId; 199 200 string url = buildUrl("devices/%s".format(device_id)); 201 202 JSONValue req = JSONValue(); 203 req["display_name"] = name; 204 205 put(url, req); 206 } 207 208 /// ditto 209 string[] getJoinedRooms() 210 { 211 string url = buildUrl("joined_rooms"); 212 213 JSONValue result = get(url); 214 215 // TODO: Find a better way to do this 💀 216 string[] rooms = []; 217 foreach (r; result["joined_rooms"].array) 218 { 219 rooms ~= r.str; 220 } 221 return rooms; 222 } 223 224 /// Joins a room by it's room id or alias, retuns it's room id 225 string joinRoom(string roomId) 226 { 227 // Why the hell are there 2 endpoints that do the *exact* same thing 228 string url = buildUrl("join/%s".format(translateRoomId(roomId))); 229 230 JSONValue ret = post(url); 231 return ret["room_id"].str; 232 } 233 234 /// Fetch new events 235 void sync() 236 { 237 import std.stdio; 238 239 string[string] params; 240 if (nextBatch) 241 params["since"] = nextBatch; 242 243 string url = buildUrl("sync", params); 244 245 JSONValue response = get(url); 246 247 nextBatch = response["next_batch"].str; 248 if ("rooms" in response) 249 { 250 JSONValue rooms = response["rooms"]; 251 252 if ("invite" in rooms) 253 { 254 JSONValue invites = rooms["invite"]; 255 256 // I hate JSON dictionaries 257 foreach (inv; invites.object.keys) 258 { 259 if (inviteDelegate) 260 inviteDelegate(inv, invites[inv]["invite_state"]["events"][0]["sender"].str); 261 } 262 263 } 264 265 if ("join" in rooms) 266 { 267 foreach (roomId; rooms["join"].object.keys) 268 { 269 if ("timeline" in rooms["join"][roomId]) 270 { 271 if ("events" in rooms["join"][roomId]["timeline"]) 272 { 273 foreach (ev; rooms["join"][roomId]["timeline"]["events"].array) 274 { 275 MatrixEvent e; 276 switch (ev["type"].str) 277 { 278 // New message 279 case "m.room.message": 280 JSONValue content = ev["content"]; 281 if (!("msgtype" in content)) 282 break; 283 string msgtype = ev["content"]["msgtype"].str; 284 MatrixMessage msg; 285 switch (msgtype) 286 { 287 case "m.text": 288 case "m.notice": 289 case "m.emote": 290 MatrixTextMessage text = new MatrixTextMessage(); 291 292 if ("body" in content) 293 text.content = content["body"].str; 294 if ("format" in content) 295 text.format = content["format"].str; 296 if ("formatted_body" in content) 297 text.formattedContent 298 = content["formatted_body"].str; 299 300 msg = text; 301 break; 302 // TODO 303 default: 304 case "m.file": 305 case "m.image": 306 case "m.audio": 307 case "m.video": 308 case "m.location": 309 msg = new MatrixMessage(); 310 break; 311 } 312 msg.msgtype = msgtype; 313 e = msg; 314 break; 315 316 case "m.reaction": 317 MatrixReaction r = new MatrixReaction(); 318 319 JSONValue relatesTo = ev["content"]["m.relates_to"]; 320 r.emoji = relatesTo["key"].str; 321 r.relatesToEvent = relatesTo["event_id"].str; 322 r.relType = relatesTo["rel_type"].str; 323 e = r; 324 break; 325 326 // Unknown events 327 default: 328 case "m.room.member": 329 e = new MatrixEvent(); 330 break; 331 } 332 /// Common event properties 333 334 e.type = ev["type"].str; 335 e.roomId = roomId; 336 e.age = ev["unsigned"]["age"].integer; 337 e.sender = ev["sender"].str; 338 e.eventId = ev["event_id"].str; 339 340 if(eventDelegate) 341 eventDelegate(e); 342 } 343 } 344 } 345 } 346 } 347 } 348 } 349 350 /// Sets the position of the read marker for given room 351 void markRead(string roomId, string eventId) 352 { 353 string url = buildUrl("rooms/%s/read_markers".format(translateRoomId(roomId))); 354 355 JSONValue req = JSONValue(); 356 req["m.fully_read"] = eventId; 357 req["m.read"] = eventId; 358 359 post(url, req); 360 } 361 362 /// Called when a new message is received 363 void delegate(MatrixEvent) eventDelegate; 364 /// Called when a new invite is received 365 void delegate(string, string) inviteDelegate; 366 367 /// Sends a m.room.message with format of org.matrix.custom.html 368 /// fallback is the plain text version of html if the client doesn't support html 369 void sendHTML(string roomId, string html, string fallback = null) 370 { 371 string url = buildUrl("rooms/%s/send/m.room.message/%d".format(translateRoomId(roomId), 372 transactionId)); 373 374 if (!fallback) 375 fallback = html; 376 JSONValue req = JSONValue(); 377 req["msgtype"] = getTextMessageType(); 378 req["format"] = "org.matrix.custom.html"; 379 req["formatted_body"] = html; 380 req["body"] = fallback; 381 382 put(url, req); 383 384 transactionId++; 385 } 386 387 /// Sends a m.room.message 388 void sendString(string roomId, string text) 389 { 390 string url = buildUrl("rooms/%s/send/m.room.message/%d".format(translateRoomId(roomId), 391 transactionId)); 392 393 JSONValue req = JSONValue(); 394 req["msgtype"] = getTextMessageType(); 395 req["body"] = text; 396 397 put(url, req); 398 399 transactionId++; 400 } 401 402 /// Sends a m.room.message with specified msgtype and MXC URI 403 void sendFile(string roomId, string filename, string mxc, string msgtype = "m.file") 404 { 405 string url = buildUrl("rooms/%s/send/m.room.message/%d".format(translateRoomId(roomId), 406 transactionId)); 407 408 JSONValue req = JSONValue(); 409 req["msgtype"] = msgtype; 410 req["url"] = mxc; 411 req["body"] = filename; 412 413 put(url, req); 414 415 transactionId++; 416 } 417 418 /// Sends a m.room.message with type of m.image with specified MXC URI 419 void sendImage(string roomId, string filename, string mxc) 420 { 421 sendFile(roomId, "m.image", filename, mxc); 422 } 423 424 /// Uploads a file to the server and returns the MXC URI 425 string uploadFile(const void[] data, string filename, string mimetype) 426 { 427 string[string] params = ["filename": filename]; 428 string url = buildUrl("upload", params, "r0", "media"); 429 430 // TODO: Ratelimits 431 HTTP http = HTTP(); 432 http.postData(data); 433 http.addRequestHeader("Content-Type", mimetype); 434 JSONValue resp = makeHttpRequest!("POST")(url, JSONValue(), http); 435 436 return resp["content_uri"].str; 437 } 438 439 void addReaction(string room_id, string event_id, string emoji) 440 { 441 string url = buildUrl("rooms/%s/send/m.reaction/%d".format(translateRoomId(room_id), 442 transactionId)); 443 444 JSONValue req = JSONValue(); 445 req["m.relates_to"] = JSONValue(); 446 req["m.relates_to"]["rel_type"] = "m.annotation"; 447 req["m.relates_to"]["event_id"] = event_id; 448 req["m.relates_to"]["key"] = emoji; 449 450 put(url, req); 451 452 transactionId++; 453 } 454 455 /// Resolves the room alias to a room id, no authentication required 456 string resolveRoomAlias(string roomalias) 457 { 458 string url = buildUrl("directory/room/%s".format(translate(roomalias, 459 ['#': "%23", ':': "%3A"]))); 460 461 JSONValue resp = get(url); 462 463 return resp["room_id"].str; 464 } 465 466 /// Sets your presence 467 /// NOTE: No clients support status messages yet 468 void setPresence(MatrixPresenceEnum presence, string status_msg = null) 469 { 470 string url = buildUrl("presence/%s/status".format(userId)); 471 472 JSONValue req; 473 req["presence"] = presence; 474 if (status_msg) 475 req["status_msg"] = status_msg; 476 else 477 req["status_msg"] = ""; 478 479 put(url, req); 480 } 481 482 /// Gets the specified user's presence 483 MatrixPresence getPresence(string userId = null) 484 { 485 if (!userId) 486 userId = this.userId; 487 488 string url = buildUrl("presence/%s/status".format(userId)); 489 490 JSONValue resp = get(url); 491 import std.stdio; 492 493 writeln(resp); 494 MatrixPresence p = new MatrixPresence(); 495 if ("currently_active" in resp) 496 p.currentlyActive = resp["currently_active"].boolean; 497 p.lastActiveAgo = resp["last_active_ago"].integer; 498 p.presence = resp["presence"].str.to!MatrixPresenceEnum; 499 if (!resp["status_msg"].isNull) 500 p.statusMessage = resp["status_msg"].str; 501 502 return p; 503 } 504 505 /// Gets custom account data with specified type 506 JSONValue getAccountData(string type) 507 { 508 string url = buildUrl("user/%s/account_data/%s".format(userId, type)); 509 510 JSONValue resp = get(url); 511 512 return resp; 513 } 514 515 /// Sets custom account data for specified type 516 void setAccountData(string type, JSONValue data) 517 { 518 string url = buildUrl("user/%s/account_data/%s".format(userId, type)); 519 520 put(url, data); 521 } 522 523 /// Get custom account data with specified type for the given room 524 /// NOTE: Room aliases don't have the same data as their resolved room ids 525 /// NOTE 2: Synapse doesn't seem to validate the room id, so you can put anything in place of it 526 JSONValue getRoomData(string room_id, string type) 527 { 528 string url = buildUrl("user/%s/rooms/%s/account_data/%s".format(userId, 529 translateRoomId(room_id), type)); 530 531 JSONValue resp = get(url); 532 533 return resp; 534 } 535 536 /// Set custom account data with specified type for the given room 537 /// NOTE: Room aliases don't have the same data as their resolved room ids 538 /// NOTE 2: Synapse doesn't seem to validate the room id, so you can put anything in place of it 539 void setRoomData(string room_id, string type, JSONValue data) 540 { 541 string url = buildUrl("user/%s/rooms/%s/account_data/%s".format(userId, 542 translateRoomId(room_id), type)); 543 544 put(url, data); 545 } 546 } 547 548 class MatrixException : Exception 549 { 550 string errcode, error; 551 int statuscode; 552 this(int statuscode, JSONValue json) 553 { 554 this.statuscode = statuscode; 555 if ("errcode" in json) 556 errcode = json["errcode"].str; 557 if ("error" in json) 558 error = json["error"].str; 559 560 super(statuscode.to!string ~ " - " ~ errcode ~ ":" ~ error); 561 } 562 } 563 564 class MatrixEvent 565 { 566 string sender, roomId, eventId, type; 567 long age; 568 } 569 570 class MatrixReaction : MatrixEvent 571 { 572 string relType, relatesToEvent, emoji; 573 } 574 575 class MatrixMessage : MatrixEvent 576 { 577 string msgtype; 578 } 579 580 class MatrixTextMessage : MatrixMessage 581 { 582 string content, format, formattedContent; 583 } 584 585 class MatrixDeviceInfo 586 { 587 string deviceId, displayName, lastSeenIP; 588 // I have no idea how to convert UNIX timestamps to DateTime 589 long lastSeen; 590 } 591 592 class MatrixPresence 593 { 594 bool currentlyActive; 595 long lastActiveAgo; 596 MatrixPresenceEnum presence; 597 string statusMessage; 598 } 599 600 enum MatrixPresenceEnum : string 601 { 602 online = "online", 603 offline = "offline", 604 unavailable = "unavailable" 605 }