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 switch (ev["type"].str) 276 { 277 // New message 278 case "m.room.message": 279 auto content = ev["content"]; 280 if (!("msgtype" in content)) 281 break; 282 string msgtype = ev["content"]["msgtype"].str; 283 switch (msgtype) 284 { 285 case "m.text": 286 case "m.notice": 287 if (messageDelegate) 288 { 289 MatrixTextMessage text = new MatrixTextMessage(); 290 291 text.roomId = roomId; 292 text.type = msgtype; 293 text.age = ev["unsigned"]["age"].integer; 294 text.author = ev["sender"].str; 295 text.eventId = ev["event_id"].str; 296 297 if ("body" in content) 298 text.content = content["body"].str; 299 if ("format" in content) 300 text.format = content["format"].str; 301 if ("formatted_body" in content) 302 text.formattedContent 303 = content["formatted_body"].str; 304 305 messageDelegate(text); 306 } 307 break; 308 309 // TODO 310 default: 311 case "m.file": 312 case "m.image": 313 case "m.audio": 314 case "m.video": 315 if (messageDelegate) 316 { 317 MatrixMessage msg = new MatrixMessage(); 318 319 msg.roomId = roomId; 320 msg.type = msgtype; 321 msg.age = ev["unsigned"]["age"].integer; 322 msg.author = ev["sender"].str; 323 msg.eventId = ev["event_id"].str; 324 325 messageDelegate(msg); 326 } 327 } 328 329 break; 330 // Membership change 331 case "m.room.member": 332 break; 333 default: 334 break; 335 } 336 } 337 } 338 } 339 } 340 } 341 } 342 } 343 344 /// Sets the position of the read marker for given room 345 void markRead(string roomId, string eventId) 346 { 347 string url = buildUrl("rooms/%s/read_markers".format(translateRoomId(roomId))); 348 349 JSONValue req = JSONValue(); 350 req["m.fully_read"] = eventId; 351 req["m.read"] = eventId; 352 353 post(url, req); 354 } 355 356 /// Called when a new message is received 357 void delegate(MatrixMessage) messageDelegate; 358 /// Called when a new invite is received 359 void delegate(string, string) inviteDelegate; 360 361 /// Sends a m.room.message with format of org.matrix.custom.html 362 /// fallback is the plain text version of html if the client doesn't support html 363 void sendHTML(string roomId, string html, string fallback = null) 364 { 365 string url = buildUrl("rooms/%s/send/m.room.message/%d".format(translateRoomId(roomId), 366 transactionId)); 367 368 if (!fallback) 369 fallback = html; 370 JSONValue req = JSONValue(); 371 req["msgtype"] = getTextMessageType(); 372 req["format"] = "org.matrix.custom.html"; 373 req["formatted_body"] = html; 374 req["body"] = fallback; 375 376 put(url, req); 377 378 transactionId++; 379 } 380 381 /// Sends a m.room.message 382 void sendString(string roomId, string text) 383 { 384 string url = buildUrl("rooms/%s/send/m.room.message/%d".format(translateRoomId(roomId), 385 transactionId)); 386 387 JSONValue req = JSONValue(); 388 req["msgtype"] = getTextMessageType(); 389 req["body"] = text; 390 391 put(url, req); 392 393 transactionId++; 394 } 395 396 /// Sends a m.room.message with specified msgtype and MXC URI 397 void sendFile(string roomId, string filename, string mxc, string msgtype = "m.file") 398 { 399 string url = buildUrl("rooms/%s/send/m.room.message/%d".format(translateRoomId(roomId), 400 transactionId)); 401 402 JSONValue req = JSONValue(); 403 req["msgtype"] = msgtype; 404 req["url"] = mxc; 405 req["body"] = filename; 406 407 put(url, req); 408 409 transactionId++; 410 } 411 412 /// Sends a m.room.message with type of m.image with specified MXC URI 413 void sendImage(string roomId, string filename, string mxc) 414 { 415 sendFile(roomId, "m.image", filename, mxc); 416 } 417 418 /// Uploads a file to the server and returns the MXC URI 419 string uploadFile(const void[] data, string filename, string mimetype) 420 { 421 string[string] params = ["filename": filename]; 422 string url = buildUrl("upload", params, "r0", "media"); 423 424 // TODO: Ratelimits 425 HTTP http = HTTP(); 426 http.postData(data); 427 http.addRequestHeader("Content-Type", mimetype); 428 JSONValue resp = makeHttpRequest!("POST")(url, JSONValue(), http); 429 430 return resp["content_uri"].str; 431 } 432 433 /// Resolves the room alias to a room id, no authentication required 434 string resolveRoomAlias(string roomalias) 435 { 436 string url = buildUrl("directory/room/%s".format(translate(roomalias, 437 ['#': "%23", ':': "%3A"]))); 438 439 JSONValue resp = get(url); 440 441 return resp["room_id"].str; 442 } 443 444 /// Sets your presence 445 /// NOTE: No clients support status messages yet 446 void setPresence(MatrixPresenceEnum presence, string status_msg = null) 447 { 448 string url = buildUrl("presence/%s/status".format(userId)); 449 450 JSONValue req; 451 req["presence"] = presence; 452 if (status_msg) 453 req["status_msg"] = status_msg; 454 else 455 req["status_msg"] = ""; 456 457 put(url, req); 458 } 459 460 /// Gets the specified user's presence 461 MatrixPresence getPresence(string userId = null) 462 { 463 if (!userId) 464 userId = this.userId; 465 466 string url = buildUrl("presence/%s/status".format(userId)); 467 468 JSONValue resp = get(url); 469 import std.stdio; 470 471 writeln(resp); 472 MatrixPresence p = new MatrixPresence(); 473 if ("currently_active" in resp) 474 p.currentlyActive = resp["currently_active"].boolean; 475 p.lastActiveAgo = resp["last_active_ago"].integer; 476 p.presence = resp["presence"].str.to!MatrixPresenceEnum; 477 if (!resp["status_msg"].isNull) 478 p.statusMessage = resp["status_msg"].str; 479 480 return p; 481 } 482 483 /// Gets custom account data with specified type 484 JSONValue getAccountData(string type) 485 { 486 string url = buildUrl("user/%s/account_data/%s".format(userId, type)); 487 488 JSONValue resp = get(url); 489 490 return resp; 491 } 492 493 /// Sets custom account data for specified type 494 void setAccountData(string type, JSONValue data) 495 { 496 string url = buildUrl("user/%s/account_data/%s".format(userId, type)); 497 498 put(url, data); 499 } 500 501 /// Get custom account data with specified type for the given room 502 /// NOTE: Room aliases don't have the same data as their resolved room ids 503 /// NOTE 2: Synapse doesn't seem to validate the room id, so you can put anything in place of it 504 JSONValue getRoomData(string room_id, string type) 505 { 506 string url = buildUrl("user/%s/rooms/%s/account_data/%s".format(userId, 507 translateRoomId(room_id), type)); 508 509 JSONValue resp = get(url); 510 511 return resp; 512 } 513 514 /// Set custom account data with specified type for the given room 515 /// NOTE: Room aliases don't have the same data as their resolved room ids 516 /// NOTE 2: Synapse doesn't seem to validate the room id, so you can put anything in place of it 517 void setRoomData(string room_id, string type, JSONValue data) 518 { 519 string url = buildUrl("user/%s/rooms/%s/account_data/%s".format(userId, 520 translateRoomId(room_id), type)); 521 522 put(url, data); 523 } 524 } 525 526 class MatrixException : Exception 527 { 528 string errcode, error; 529 int statuscode; 530 this(int statuscode, JSONValue json) 531 { 532 this.statuscode = statuscode; 533 if ("errcode" in json) 534 errcode = json["errcode"].str; 535 if ("error" in json) 536 error = json["error"].str; 537 538 super(statuscode.to!string ~ " - " ~ errcode ~ ":" ~ error); 539 } 540 } 541 542 class MatrixMessage 543 { 544 string author, type, roomId, eventId; 545 long age; 546 } 547 548 class MatrixTextMessage : MatrixMessage 549 { 550 string content, format, formattedContent; 551 } 552 553 class MatrixDeviceInfo 554 { 555 string deviceId, displayName, lastSeenIP; 556 // I have no idea how to convert UNIX timestamps to DateTime 557 long lastSeen; 558 } 559 560 class MatrixPresence 561 { 562 bool currentlyActive; 563 long lastActiveAgo; 564 MatrixPresenceEnum presence; 565 string statusMessage; 566 } 567 568 enum MatrixPresenceEnum : string 569 { 570 online = "online", 571 offline = "offline", 572 unavailable = "unavailable" 573 }