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