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 }