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 }