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 else static if (method == "OPTIONS") 74 http.method(HTTP.Method.options); 75 76 //import std.stdio; 77 //writeln(method ~ " " ~ url); 78 //writeln(data.toString); 79 80 if (!data.isNull) 81 http.postData(data.toString); 82 http.onReceive = (ubyte[] data) { 83 returnstr ~= cast(string) data; 84 return data.length; 85 }; 86 //http.verbose(true); 87 CurlCode c = http.perform(ThrowOnError.no); 88 //writeln(c); 89 //writeln(returnstr); 90 returnbody = parseJSON(returnstr); 91 if (c) 92 { 93 throw new MatrixException(c, returnbody); 94 } 95 return returnbody; 96 } 97 98 JSONValue get(string url, JSONValue data = JSONValue()) 99 { 100 return makeHttpRequest!("GET")(url, data); 101 } 102 103 JSONValue post(string url, JSONValue data = JSONValue()) 104 { 105 return makeHttpRequest!("POST")(url, data); 106 } 107 108 JSONValue put(string url, JSONValue data = JSONValue()) 109 { 110 // Using the HTTP struct with PUT seems to hang 111 // return makeHttpRequest!("PUT")(url, data); 112 113 // std.net.curl.put works fine 114 import std.net.curl : cput = put; 115 116 return parseJSON(cput(url, data.toString())); 117 } 118 119 JSONValue options(string url, JSONValue data = JSONValue()) 120 { 121 return makeHttpRequest!("OPTIONS")(url, data); 122 } 123 124 string homeserver, userId, accessToken, deviceId; 125 /// Should sync() keep the JSONValue reference 126 bool syncKeepJSONEventReference = false; 127 128 this(string homeserver = "https://matrix.org") 129 { 130 this.homeserver = homeserver; 131 // Check well known matrix 132 } 133 134 /// Log in to the matrix server using a username and password. 135 /// deviceId is optional, if none provided, server will generate it's own 136 /// If provided, server will invalidate the previous access token for this device 137 void passwordLogin(string user, string password, string device_id = null) 138 { 139 string url = buildUrl("login"); 140 JSONValue req = JSONValue(); 141 req["type"] = "m.login.password"; 142 req["user"] = user; 143 req["password"] = password; 144 if (device_id) 145 req["device_id"] = device_id; 146 147 JSONValue resp = post(url, req); 148 149 this.accessToken = resp["access_token"].str; 150 this.userId = resp["user_id"].str; 151 this.deviceId = resp["device_id"].str; 152 } 153 154 /// Log in to the matrix server using an existing access token assigned to a device_id. 155 void tokenLogin(string access_token, string device_id) 156 { 157 this.accessToken = access_token; 158 this.deviceId = device_id; 159 160 string url = buildUrl("account/whoami"); 161 JSONValue ret = get(url); 162 163 userId = ret["user_id"].str; 164 deviceId = ret["device_id"].str; 165 } 166 167 /// Get information about all devices for current user 168 MatrixDeviceInfo[] getDevices() 169 { 170 string url = buildUrl("devices"); 171 JSONValue ret = get(url); 172 173 MatrixDeviceInfo[] inf; 174 foreach (d; ret["devices"].array) 175 { 176 MatrixDeviceInfo i = new MatrixDeviceInfo(); 177 i.deviceId = d["device_id"].str; 178 if (!d["display_name"].isNull) 179 i.displayName = d["display_name"].str; 180 if (!d["last_seen_ip"].isNull) 181 i.lastSeenIP = d["last_seen_ip"].str; 182 if (!d["last_seen_ts"].isNull) 183 i.lastSeen = d["last_seen_ts"].integer; 184 185 inf ~= i; 186 } 187 188 return inf; 189 } 190 191 /// Get information for a single device by it's device id 192 MatrixDeviceInfo getDeviceInfo(string device_id) 193 { 194 string url = buildUrl("devices/%s".format(device_id)); 195 JSONValue ret = get(url); 196 197 MatrixDeviceInfo i = new MatrixDeviceInfo(); 198 i.deviceId = ret["device_id"].str; 199 if (!ret["display_name"].isNull) 200 i.displayName = ret["display_name"].str; 201 if (!ret["last_seen_ip"].isNull) 202 i.lastSeenIP = ret["last_seen_ip"].str; 203 if (!ret["last_seen_ts"].isNull) 204 i.lastSeen = ret["last_seen_ts"].integer; 205 206 return i; 207 } 208 209 /// Updates the display name for a device 210 /// device_id is optional, if null, current device ID will be used 211 void setDeviceName(string name, string device_id = null) 212 { 213 if (!device_id) 214 device_id = deviceId; 215 216 string url = buildUrl("devices/%s".format(device_id)); 217 218 JSONValue req = JSONValue(); 219 req["display_name"] = name; 220 221 put(url, req); 222 } 223 224 /// Deletes devices, uses a password for authentication 225 /// NOTE: This will only work if the homeserver requires ONLY a password authentication 226 void deleteDevicesUsingPassword(string[] devices, string password) 227 { 228 string url = buildUrl("delete_devices"); 229 230 string session; 231 JSONValue noauthresp; 232 233 // This is gonna reply with 401 and give us the session 234 try 235 { 236 // Freezes here :/ 237 noauthresp = post(url); 238 } 239 catch (MatrixException e) 240 { 241 noauthresp = e.json; 242 } 243 244 session = noauthresp["session"].str; 245 246 JSONValue req = JSONValue(); 247 req["auth"] = JSONValue(); 248 req["auth"]["session"] = session; 249 req["auth"]["type"] = "m.login.password"; 250 req["auth"]["user"] = userId; 251 req["auth"]["identifier"] = JSONValue(); 252 req["auth"]["identifier"]["type"] = "m.id.user"; 253 req["auth"]["identifier"]["user"] = userId; 254 req["auth"]["password"] = password; 255 req["devices"] = devices; 256 257 post(url, req); 258 } 259 260 /// ditto 261 string[] getJoinedRooms() 262 { 263 string url = buildUrl("joined_rooms"); 264 265 JSONValue result = get(url); 266 267 // TODO: Find a better way to do this 💀 268 string[] rooms = []; 269 foreach (r; result["joined_rooms"].array) 270 { 271 rooms ~= r.str; 272 } 273 return rooms; 274 } 275 276 /// Joins a room by it's room id or alias, retuns it's room id 277 string joinRoom(string roomId) 278 { 279 // Why the hell are there 2 endpoints that do the *exact* same thing 280 string url = buildUrl("join/%s".format(translateRoomId(roomId))); 281 282 JSONValue ret = post(url); 283 return ret["room_id"].str; 284 } 285 286 /// Fetch new events 287 void sync() 288 { 289 import std.stdio; 290 291 string[string] params; 292 if (nextBatch) 293 params["since"] = nextBatch; 294 295 string url = buildUrl("sync", params); 296 297 JSONValue response = get(url); 298 299 nextBatch = response["next_batch"].str; 300 if ("rooms" in response) 301 { 302 JSONValue rooms = response["rooms"]; 303 304 if ("invite" in rooms) 305 { 306 JSONValue invites = rooms["invite"]; 307 308 // I hate JSON dictionaries 309 foreach (inv; invites.object.keys) 310 { 311 if (inviteDelegate) 312 inviteDelegate(inv, invites[inv]["invite_state"]["events"][0]["sender"].str); 313 } 314 315 } 316 317 if ("join" in rooms) 318 { 319 foreach (roomId; rooms["join"].object.keys) 320 { 321 if ("timeline" in rooms["join"][roomId]) 322 { 323 if ("events" in rooms["join"][roomId]["timeline"]) 324 { 325 foreach (ev; rooms["join"][roomId]["timeline"]["events"].array) 326 { 327 MatrixEvent e = parseEvent(ev, syncKeepJSONEventReference); 328 if (eventDelegate) 329 eventDelegate(e); 330 } 331 } 332 } 333 } 334 } 335 } 336 } 337 338 /// Parses an event from a JSONValue, use casting or the type field to determine it's type. 339 /// keepJSONReference determines if the JSONValue should be kept in the MatrixEvent object. 340 /// You can override this function in your program if you need support for more event types. 341 MatrixEvent parseEvent(JSONValue ev, bool keepJSONReference = false) 342 { 343 MatrixEvent e; 344 345 switch (ev["type"].str) 346 { 347 // New message 348 case "m.room.message": 349 JSONValue content = ev["content"]; 350 if (!("msgtype" in content)) 351 break; 352 string msgtype = ev["content"]["msgtype"].str; 353 MatrixMessage msg; 354 switch (msgtype) 355 { 356 case "m.text": 357 case "m.notice": 358 case "m.emote": 359 MatrixTextMessage text = new MatrixTextMessage(); 360 361 if ("format" in content) 362 text.format = content["format"].str; 363 if ("formatted_body" in content) 364 text.formattedContent 365 = content["formatted_body"].str; 366 367 msg = text; 368 break; 369 // TODO 370 default: 371 case "m.file": 372 case "m.image": 373 case "m.audio": 374 case "m.video": 375 case "m.location": 376 msg = new MatrixMessage(); 377 break; 378 } 379 380 msg.msgtype = msgtype; 381 if ("body" in content) 382 msg.content = content["body"].str; 383 e = msg; 384 break; 385 386 case "m.reaction": 387 MatrixReaction r = new MatrixReaction(); 388 389 JSONValue relatesTo = ev["content"]["m.relates_to"]; 390 r.emoji = relatesTo["key"].str; 391 r.relatesToEvent = relatesTo["event_id"].str; 392 r.relType = relatesTo["rel_type"].str; 393 e = r; 394 break; 395 396 // Unknown events 397 default: 398 case "m.room.member": 399 e = new MatrixEvent(); 400 break; 401 } 402 /// Common event properties 403 404 e.type = ev["type"].str; 405 e.roomId = ev["room_id"].str; 406 e.age = ev["unsigned"]["age"].integer; 407 e.sender = ev["sender"].str; 408 e.eventId = ev["event_id"].str; 409 410 if (keepJSONReference) 411 e.json = ev; 412 413 return e; 414 } 415 416 /// Gets an event from a room by it's ID 417 MatrixEvent getEvent(string room_id, string event_id, bool keepJSONReference = false) 418 { 419 string url = buildUrl("rooms/%s/context/%s".format(room_id, event_id)); 420 421 JSONValue req = JSONValue(); 422 req["limit"] = 1; 423 424 JSONValue res = get(url, req); 425 426 return parseEvent(res["event"], keepJSONReference); 427 } 428 429 /// Sets the position of the read marker for given room 430 void markRead(string roomId, string eventId) 431 { 432 string url = buildUrl("rooms/%s/read_markers".format(translateRoomId(roomId))); 433 434 JSONValue req = JSONValue(); 435 req["m.fully_read"] = eventId; 436 req["m.read"] = eventId; 437 438 post(url, req); 439 } 440 441 /// Called when a new message is received 442 void delegate(MatrixEvent) eventDelegate; 443 /// Called when a new invite is received 444 void delegate(string, string) inviteDelegate; 445 446 /// Sends a m.room.message with format of org.matrix.custom.html 447 /// fallback is the plain text version of html if the client doesn't support html 448 void sendHTML(string roomId, string html, string fallback = null, string msgtype = "m.notice") 449 { 450 string url = buildUrl("rooms/%s/send/m.room.message/%d".format(translateRoomId(roomId), 451 transactionId)); 452 453 if (!fallback) 454 fallback = html; 455 JSONValue req = JSONValue(); 456 req["msgtype"] = msgtype; 457 req["format"] = "org.matrix.custom.html"; 458 req["formatted_body"] = html; 459 req["body"] = fallback; 460 461 put(url, req); 462 463 transactionId++; 464 } 465 466 /// Sends a m.room.message 467 void sendString(string roomId, string text, string msgtype = "m.notice") 468 { 469 string url = buildUrl("rooms/%s/send/m.room.message/%d".format(translateRoomId(roomId), 470 transactionId)); 471 472 JSONValue req = JSONValue(); 473 req["msgtype"] = msgtype; 474 req["body"] = text; 475 476 put(url, req); 477 478 transactionId++; 479 } 480 481 /// Sends a m.room.message with specified msgtype and MXC URI 482 void sendFile(string roomId, string filename, string mxc, string msgtype = "m.file") 483 { 484 string url = buildUrl("rooms/%s/send/m.room.message/%d".format(translateRoomId(roomId), 485 transactionId)); 486 487 JSONValue req = JSONValue(); 488 req["msgtype"] = msgtype; 489 req["url"] = mxc; 490 req["body"] = filename; 491 492 put(url, req); 493 494 transactionId++; 495 } 496 497 /// Sends a m.room.message with type of m.image with specified MXC URI 498 void sendImage(string roomId, string filename, string mxc) 499 { 500 sendFile(roomId, "m.image", filename, mxc); 501 } 502 503 /// Uploads a file to the server and returns the MXC URI 504 string uploadFile(const void[] data, string filename, string mimetype) 505 { 506 string[string] params = ["filename": filename]; 507 string url = buildUrl("upload", params, "r0", "media"); 508 509 // TODO: Ratelimits 510 HTTP http = HTTP(); 511 http.postData(data); 512 http.addRequestHeader("Content-Type", mimetype); 513 JSONValue resp = makeHttpRequest!("POST")(url, JSONValue(), http); 514 515 return resp["content_uri"].str; 516 } 517 518 void addReaction(string room_id, string event_id, string emoji) 519 { 520 string url = buildUrl("rooms/%s/send/m.reaction/%d".format(translateRoomId(room_id), 521 transactionId)); 522 523 JSONValue req = JSONValue(); 524 req["m.relates_to"] = JSONValue(); 525 req["m.relates_to"]["rel_type"] = "m.annotation"; 526 req["m.relates_to"]["event_id"] = event_id; 527 req["m.relates_to"]["key"] = emoji; 528 529 put(url, req); 530 531 transactionId++; 532 } 533 534 string[] getRoomMembers(string room_id) 535 { 536 string url = buildUrl("rooms/%s/joined_members".format(translateRoomId(room_id))); 537 538 JSONValue res = get(url); 539 540 return res["joined"].object.keys; 541 } 542 543 string createRoom(MatrixRoomPresetEnum preset = MatrixRoomPresetEnum.private_chat, 544 bool showInDirectory = false, string roomAlias = null, string name = null, 545 bool is_direct = false, string[] inviteUsers = []) 546 { 547 string url = buildUrl("createRoom"); 548 549 JSONValue req = JSONValue(); 550 551 req["preset"] = preset; 552 req["visibility"] = showInDirectory ? "public" : "private"; 553 554 if (name) 555 req["name"] = name; 556 if (roomAlias) 557 req["room_alias_name"] = roomAlias; 558 559 req["is_direct"] = is_direct; 560 req["invite"] = inviteUsers; 561 562 JSONValue res = post(url, req); 563 import std.stdio; 564 565 writeln(res); 566 return res["room_id"].str; 567 } 568 569 /// Resolves the room alias to a room id, no authentication required 570 string resolveRoomAlias(string roomalias) 571 { 572 string url = buildUrl("directory/room/%s".format(translate(roomalias, 573 ['#': "%23", ':': "%3A"]))); 574 575 JSONValue resp = get(url); 576 577 return resp["room_id"].str; 578 } 579 580 /// Sets your presence 581 /// NOTE: No clients support status messages yet 582 void setPresence(MatrixPresenceEnum presence, string status_msg = null) 583 { 584 string url = buildUrl("presence/%s/status".format(userId)); 585 586 JSONValue req; 587 req["presence"] = presence; 588 if (status_msg) 589 req["status_msg"] = status_msg; 590 else 591 req["status_msg"] = ""; 592 593 put(url, req); 594 } 595 596 /// Gets the specified user's presence 597 MatrixPresence getPresence(string userId = null) 598 { 599 if (!userId) 600 userId = this.userId; 601 602 string url = buildUrl("presence/%s/status".format(userId)); 603 604 JSONValue resp = get(url); 605 import std.stdio; 606 607 writeln(resp); 608 MatrixPresence p = new MatrixPresence(); 609 if ("currently_active" in resp) 610 p.currentlyActive = resp["currently_active"].boolean; 611 p.lastActiveAgo = resp["last_active_ago"].integer; 612 p.presence = resp["presence"].str.to!MatrixPresenceEnum; 613 if (!resp["status_msg"].isNull) 614 p.statusMessage = resp["status_msg"].str; 615 616 return p; 617 } 618 619 /// Gets the direct message room for given user. 620 /// Returns null if the room doesn't exist 621 string getDirectMessageRoom(string user_id) 622 { 623 try 624 { 625 JSONValue result = getAccountData("m.direct"); 626 if ("content" in result) 627 { 628 if (user_id in result["content"]) 629 { 630 return result["content"][user_id].array.front.str; 631 } 632 } 633 } 634 catch (Exception e) 635 { 636 } 637 638 return null; 639 } 640 641 /// Creates the direct message room and stores it's ID in account data 642 string createDirectMessageRoom(string user_id) 643 { 644 /// Create the room 645 string roomId = createRoom( 646 MatrixRoomPresetEnum.private_chat, false, null, null, 647 true, [user_id]); 648 649 /// Store the room id in the account data 650 JSONValue dat = getAccountData("m.direct"); 651 import std.stdio; 652 653 writeln(dat); 654 if ("error" in dat) 655 dat = JSONValue(); 656 657 if (dat.isNull) 658 { 659 dat["content"] = JSONValue(); 660 dat["content"][user_id] = JSONValue(); 661 dat["content"][user_id] = [roomId]; 662 } 663 else 664 { 665 if (!(user_id in dat["content"])) 666 dat["content"][user_id] = [roomId]; 667 else 668 dat["content"][user_id] ~= roomId; 669 } 670 671 setAccountData("m.direct", dat); 672 return roomId; 673 } 674 675 string getOrCreateDirectMessageRoom(string user_id) 676 { 677 string roomId = getDirectMessageRoom(user_id); 678 return roomId ? roomId : createDirectMessageRoom(user_id); 679 } 680 681 /// Gets custom account data with specified type 682 JSONValue getAccountData(string type) 683 { 684 string url = buildUrl("user/%s/account_data/%s".format(userId, type)); 685 686 JSONValue resp = get(url); 687 688 return resp; 689 } 690 691 /// Sets custom account data for specified type 692 void setAccountData(string type, JSONValue data) 693 { 694 string url = buildUrl("user/%s/account_data/%s".format(userId, type)); 695 696 put(url, data); 697 } 698 699 /// Get custom account data with specified type for the given room 700 /// NOTE: Room aliases don't have the same data as their resolved room ids 701 /// NOTE 2: Synapse doesn't seem to validate the room id, so you can put anything in place of it 702 JSONValue getRoomData(string room_id, string type) 703 { 704 string url = buildUrl("user/%s/rooms/%s/account_data/%s".format(userId, 705 translateRoomId(room_id), type)); 706 707 JSONValue resp = get(url); 708 709 return resp; 710 } 711 712 /// Set custom account data with specified type for the given room 713 /// NOTE: Room aliases don't have the same data as their resolved room ids 714 /// NOTE 2: Synapse doesn't seem to validate the room id, so you can put anything in place of it 715 void setRoomData(string room_id, string type, JSONValue data) 716 { 717 string url = buildUrl("user/%s/rooms/%s/account_data/%s".format(userId, 718 translateRoomId(room_id), type)); 719 720 put(url, data); 721 } 722 } 723 724 class MatrixException : Exception 725 { 726 string errcode, error; 727 int statuscode; 728 JSONValue json; 729 this(int statuscode, JSONValue json) 730 { 731 this.json = json; 732 this.statuscode = statuscode; 733 if ("errcode" in json) 734 errcode = json["errcode"].str; 735 if ("error" in json) 736 error = json["error"].str; 737 738 super(statuscode.to!string ~ " - " ~ errcode ~ ":" ~ error); 739 } 740 } 741 742 class MatrixEvent 743 { 744 string sender, roomId, eventId, type; 745 long age; 746 JSONValue json; 747 } 748 749 class MatrixReaction : MatrixEvent 750 { 751 string relType, relatesToEvent, emoji; 752 } 753 754 class MatrixMessage : MatrixEvent 755 { 756 string msgtype, content; 757 } 758 759 class MatrixTextMessage : MatrixMessage 760 { 761 string format, formattedContent; 762 } 763 764 class MatrixDeviceInfo 765 { 766 string deviceId, displayName, lastSeenIP; 767 // I have no idea how to convert UNIX timestamps to DateTime 768 long lastSeen; 769 } 770 771 class MatrixPresence 772 { 773 bool currentlyActive; 774 long lastActiveAgo; 775 MatrixPresenceEnum presence; 776 string statusMessage; 777 } 778 779 enum MatrixRoomPresetEnum : string 780 { 781 private_chat = "private_chat", 782 public_chat = "public_chat", 783 trusted_private_chat = "trusted_private_chat" 784 } 785 786 enum MatrixPresenceEnum : string 787 { 788 online = "online", 789 offline = "offline", 790 unavailable = "unavailable" 791 }