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 }