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, roomId); 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, string optRoomId = null) 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 if("room_id" in ev) 406 e.roomId = ev["room_id"].str; 407 else if(optRoomId) e.roomId = optRoomId; 408 409 e.age = ev["unsigned"]["age"].integer; 410 e.sender = ev["sender"].str; 411 e.eventId = ev["event_id"].str; 412 413 if (keepJSONReference) 414 e.json = ev; 415 416 return e; 417 } 418 419 /// Gets an event from a room by it's ID 420 MatrixEvent getEvent(string room_id, string event_id, bool keepJSONReference = false) 421 { 422 string url = buildUrl("rooms/%s/context/%s".format(room_id, event_id)); 423 424 JSONValue req = JSONValue(); 425 req["limit"] = 1; 426 427 JSONValue res = get(url, req); 428 429 return parseEvent(res["event"], keepJSONReference, room_id); 430 } 431 432 /// Sets the position of the read marker for given room 433 void markRead(string roomId, string eventId) 434 { 435 string url = buildUrl("rooms/%s/read_markers".format(translateRoomId(roomId))); 436 437 JSONValue req = JSONValue(); 438 req["m.fully_read"] = eventId; 439 req["m.read"] = eventId; 440 441 post(url, req); 442 } 443 444 /// Called when a new message is received 445 void delegate(MatrixEvent) eventDelegate; 446 /// Called when a new invite is received 447 void delegate(string, string) inviteDelegate; 448 449 /// Sends a m.room.message with format of org.matrix.custom.html 450 /// fallback is the plain text version of html if the client doesn't support html 451 void sendHTML(string roomId, string html, string fallback = null, string msgtype = "m.notice") 452 { 453 string url = buildUrl("rooms/%s/send/m.room.message/%d".format(translateRoomId(roomId), 454 transactionId)); 455 456 if (!fallback) 457 fallback = html; 458 JSONValue req = JSONValue(); 459 req["msgtype"] = msgtype; 460 req["format"] = "org.matrix.custom.html"; 461 req["formatted_body"] = html; 462 req["body"] = fallback; 463 464 put(url, req); 465 466 transactionId++; 467 } 468 469 /// Sends a m.room.message 470 void sendString(string roomId, string text, string msgtype = "m.notice") 471 { 472 string url = buildUrl("rooms/%s/send/m.room.message/%d".format(translateRoomId(roomId), 473 transactionId)); 474 475 JSONValue req = JSONValue(); 476 req["msgtype"] = msgtype; 477 req["body"] = text; 478 479 put(url, req); 480 481 transactionId++; 482 } 483 484 /// Sends a m.room.message with specified msgtype and MXC URI 485 void sendFile(string roomId, string filename, string mxc, string msgtype = "m.file") 486 { 487 string url = buildUrl("rooms/%s/send/m.room.message/%d".format(translateRoomId(roomId), 488 transactionId)); 489 490 JSONValue req = JSONValue(); 491 req["msgtype"] = msgtype; 492 req["url"] = mxc; 493 req["body"] = filename; 494 495 put(url, req); 496 497 transactionId++; 498 } 499 500 /// Sends a m.room.message with type of m.image with specified MXC URI 501 void sendImage(string roomId, string filename, string mxc) 502 { 503 sendFile(roomId, "m.image", filename, mxc); 504 } 505 506 /// Uploads a file to the server and returns the MXC URI 507 string uploadFile(const void[] data, string filename, string mimetype) 508 { 509 string[string] params = ["filename": filename]; 510 string url = buildUrl("upload", params, "r0", "media"); 511 512 // TODO: Ratelimits 513 HTTP http = HTTP(); 514 http.postData(data); 515 http.addRequestHeader("Content-Type", mimetype); 516 JSONValue resp = makeHttpRequest!("POST")(url, JSONValue(), http); 517 518 return resp["content_uri"].str; 519 } 520 521 void addReaction(string room_id, string event_id, string emoji) 522 { 523 string url = buildUrl("rooms/%s/send/m.reaction/%d".format(translateRoomId(room_id), 524 transactionId)); 525 526 JSONValue req = JSONValue(); 527 req["m.relates_to"] = JSONValue(); 528 req["m.relates_to"]["rel_type"] = "m.annotation"; 529 req["m.relates_to"]["event_id"] = event_id; 530 req["m.relates_to"]["key"] = emoji; 531 532 put(url, req); 533 534 transactionId++; 535 } 536 537 string[] getRoomMembers(string room_id) 538 { 539 string url = buildUrl("rooms/%s/joined_members".format(translateRoomId(room_id))); 540 541 JSONValue res = get(url); 542 543 return res["joined"].object.keys; 544 } 545 546 string createRoom(MatrixRoomPresetEnum preset = MatrixRoomPresetEnum.private_chat, 547 bool showInDirectory = false, string roomAlias = null, string name = null, 548 bool is_direct = false, string[] inviteUsers = []) 549 { 550 string url = buildUrl("createRoom"); 551 552 JSONValue req = JSONValue(); 553 554 req["preset"] = preset; 555 req["visibility"] = showInDirectory ? "public" : "private"; 556 557 if (name) 558 req["name"] = name; 559 if (roomAlias) 560 req["room_alias_name"] = roomAlias; 561 562 req["is_direct"] = is_direct; 563 req["invite"] = inviteUsers; 564 565 JSONValue res = post(url, req); 566 import std.stdio; 567 568 writeln(res); 569 return res["room_id"].str; 570 } 571 572 /// Resolves the room alias to a room id, no authentication required 573 string resolveRoomAlias(string roomalias) 574 { 575 string url = buildUrl("directory/room/%s".format(translate(roomalias, 576 ['#': "%23", ':': "%3A"]))); 577 578 JSONValue resp = get(url); 579 580 return resp["room_id"].str; 581 } 582 583 /// Sets your presence 584 /// NOTE: No clients support status messages yet 585 void setPresence(MatrixPresenceEnum presence, string status_msg = null) 586 { 587 string url = buildUrl("presence/%s/status".format(userId)); 588 589 JSONValue req; 590 req["presence"] = presence; 591 if (status_msg) 592 req["status_msg"] = status_msg; 593 else 594 req["status_msg"] = ""; 595 596 put(url, req); 597 } 598 599 /// Gets the specified user's presence 600 MatrixPresence getPresence(string userId = null) 601 { 602 if (!userId) 603 userId = this.userId; 604 605 string url = buildUrl("presence/%s/status".format(userId)); 606 607 JSONValue resp = get(url); 608 import std.stdio; 609 610 writeln(resp); 611 MatrixPresence p = new MatrixPresence(); 612 if ("currently_active" in resp) 613 p.currentlyActive = resp["currently_active"].boolean; 614 p.lastActiveAgo = resp["last_active_ago"].integer; 615 p.presence = resp["presence"].str.to!MatrixPresenceEnum; 616 if (!resp["status_msg"].isNull) 617 p.statusMessage = resp["status_msg"].str; 618 619 return p; 620 } 621 622 /// Gets the direct message room for given user. 623 /// Returns null if the room doesn't exist 624 string getDirectMessageRoom(string user_id) 625 { 626 try 627 { 628 JSONValue result = getAccountData("m.direct"); 629 if ("content" in result) 630 { 631 if (user_id in result["content"]) 632 { 633 return result["content"][user_id].array.front.str; 634 } 635 } 636 } 637 catch (Exception e) 638 { 639 } 640 641 return null; 642 } 643 644 /// Creates the direct message room and stores it's ID in account data 645 string createDirectMessageRoom(string user_id) 646 { 647 /// Create the room 648 string roomId = createRoom( 649 MatrixRoomPresetEnum.private_chat, false, null, null, 650 true, [user_id]); 651 652 /// Store the room id in the account data 653 JSONValue dat = getAccountData("m.direct"); 654 import std.stdio; 655 656 writeln(dat); 657 if ("error" in dat) 658 dat = JSONValue(); 659 660 if (dat.isNull) 661 { 662 dat["content"] = JSONValue(); 663 dat["content"][user_id] = JSONValue(); 664 dat["content"][user_id] = [roomId]; 665 } 666 else 667 { 668 if (!(user_id in dat["content"])) 669 dat["content"][user_id] = [roomId]; 670 else 671 dat["content"][user_id] ~= roomId; 672 } 673 674 setAccountData("m.direct", dat); 675 return roomId; 676 } 677 678 string getOrCreateDirectMessageRoom(string user_id) 679 { 680 string roomId = getDirectMessageRoom(user_id); 681 return roomId ? roomId : createDirectMessageRoom(user_id); 682 } 683 684 /// Gets custom account data with specified type 685 JSONValue getAccountData(string type) 686 { 687 string url = buildUrl("user/%s/account_data/%s".format(userId, type)); 688 689 JSONValue resp = get(url); 690 691 return resp; 692 } 693 694 /// Sets custom account data for specified type 695 void setAccountData(string type, JSONValue data) 696 { 697 string url = buildUrl("user/%s/account_data/%s".format(userId, type)); 698 699 put(url, data); 700 } 701 702 /// Get custom account data with specified type for the given room 703 /// NOTE: Room aliases don't have the same data as their resolved room ids 704 /// NOTE 2: Synapse doesn't seem to validate the room id, so you can put anything in place of it 705 JSONValue getRoomData(string room_id, string type) 706 { 707 string url = buildUrl("user/%s/rooms/%s/account_data/%s".format(userId, 708 translateRoomId(room_id), type)); 709 710 JSONValue resp = get(url); 711 712 return resp; 713 } 714 715 /// Set custom account data with specified type for the given room 716 /// NOTE: Room aliases don't have the same data as their resolved room ids 717 /// NOTE 2: Synapse doesn't seem to validate the room id, so you can put anything in place of it 718 void setRoomData(string room_id, string type, JSONValue data) 719 { 720 string url = buildUrl("user/%s/rooms/%s/account_data/%s".format(userId, 721 translateRoomId(room_id), type)); 722 723 put(url, data); 724 } 725 } 726 727 class MatrixException : Exception 728 { 729 string errcode, error; 730 int statuscode; 731 JSONValue json; 732 this(int statuscode, JSONValue json) 733 { 734 this.json = json; 735 this.statuscode = statuscode; 736 if ("errcode" in json) 737 errcode = json["errcode"].str; 738 if ("error" in json) 739 error = json["error"].str; 740 741 super(statuscode.to!string ~ " - " ~ errcode ~ ":" ~ error); 742 } 743 } 744 745 class MatrixEvent 746 { 747 string sender, roomId, eventId, type; 748 long age; 749 JSONValue json; 750 } 751 752 class MatrixReaction : MatrixEvent 753 { 754 string relType, relatesToEvent, emoji; 755 } 756 757 class MatrixMessage : MatrixEvent 758 { 759 string msgtype, content; 760 } 761 762 class MatrixTextMessage : MatrixMessage 763 { 764 string format, formattedContent; 765 } 766 767 class MatrixDeviceInfo 768 { 769 string deviceId, displayName, lastSeenIP; 770 // I have no idea how to convert UNIX timestamps to DateTime 771 long lastSeen; 772 } 773 774 class MatrixPresence 775 { 776 bool currentlyActive; 777 long lastActiveAgo; 778 MatrixPresenceEnum presence; 779 string statusMessage; 780 } 781 782 enum MatrixRoomPresetEnum : string 783 { 784 private_chat = "private_chat", 785 public_chat = "public_chat", 786 trusted_private_chat = "trusted_private_chat" 787 } 788 789 enum MatrixPresenceEnum : string 790 { 791 online = "online", 792 offline = "offline", 793 unavailable = "unavailable" 794 }