1 /**
2  * A simple Matrix client. Experimental!
3  *
4  * License:
5  *   This Source Code Form is subject to the terms of
6  *   the Mozilla Public License, v. 2.0. If a copy of
7  *   the MPL was not distributed with this file, You
8  *   can obtain one at http://mozilla.org/MPL/2.0/.
9  *
10  * Authors:
11  *   Vladimir Panteleev <ae@cy.md>
12  */
13 
14 module ae.net.matrix.client;
15 
16 import core.time;
17 
18 import std.conv : to;
19 import std.exception;
20 import std.json : parseJSON, JSONValue;
21 
22 import ae.net.http.client;
23 import ae.net.matrix.common;
24 import ae.sys.data;
25 import ae.sys.dataset;
26 import ae.sys.log;
27 import ae.sys.timing;
28 import ae.utils.array;
29 import ae.utils.json : JSONFragment;
30 import ae.utils.promise;
31 import ae.utils.text : randomString;
32 
33 final class MatrixClient
34 {
35 private:
36 	string host;
37 	string clientAccessToken;
38 	Promise!string serverBaseURL;
39 
40 	Promise!string getServerHost()
41 	{
42 		return serverBaseURL.require({
43 			auto p = new Promise!string;
44 			httpGet("https://" ~ host ~ "/.well-known/matrix/client",
45 				(string s) { p.fulfill(s.parseJSON()["m.homeserver"]["base_url"].str); },
46 				(string err) { p.reject(new Exception(err)); },
47 			);
48 			return p;
49 		}());
50 	}
51 
52 	enum timeout = 5.minutes;
53 
54 	/// Send a request (with retry).
55 	Promise!JSONValue send(string path, string method = "GET", JSONFragment data = JSONFragment.init)
56 	{
57 		auto p = new Promise!JSONValue;
58 		getServerHost().then(
59 			(string baseURL)
60 			{
61 				int retries;
62 				void request()
63 				{
64 					void retry(Duration backoff, string msg)
65 					{
66 						if (retries++ >= 10)
67 							return p.reject(new Exception(msg));
68 						setTimeout(&request, backoff);
69 					}
70 
71 					auto url = baseURL ~ path;
72 					auto r = new HttpRequest(url);
73 					r.headers["Authorization"] = "Bearer " ~ clientAccessToken;
74 					r.method = method;
75 					if (data)
76 					{
77 						r.headers["Content-Type"] = "application/json";
78 						r.data = DataVec(Data(data.json.asBytes));
79 					}
80 					auto c = new HttpsClient(timeout);
81 					c.handleResponse = (HttpResponse response, string disconnectReason)
82 					{
83 						try
84 						{
85 							if (!response)
86 								return retry(10.seconds, disconnectReason);
87 							enforce(response.headers.get("Content-Type", null) == "application/json", "Expected application/json");
88 							auto j = response.getContent().asDataOf!char.toGC.parseJSON();
89 							if (response.status == HttpStatusCode.TooManyRequests)
90 								return retry(
91 									j.object.get("retry_after_ms", JSONValue(10_000)).integer.msecs,
92 									j.object.get("error", JSONValue(response.statusMessage)).str,
93 								);
94 							if (response.status != HttpStatusCode.OK)
95 								throw new Exception(j.object.get("error", JSONValue(response.statusMessage)).str);
96 							p.fulfill(j);
97 						}
98 						catch (Exception e)
99 							p.reject(e);
100 					};
101 					c.request(r);
102 				}
103 				request();
104 			}
105 		);
106 		return p;
107 	}
108 
109 	alias Subscription = void delegate(JSONValue);
110 	Subscription[] subscriptions;
111 
112 	void sync(string since = null)
113 	{
114 		auto queryParameters = [
115 			"timeout": timeout.total!"msecs".to!string,
116 		];
117 		if (since)
118 			queryParameters["since"] = since;
119 		send("/_matrix/client/v3/sync?" ~ encodeUrlParameters(queryParameters))
120 			.then((JSONValue j) {
121 				if (since)
122 					foreach (subscription; subscriptions)
123 						subscription(j);
124 				sync(j["next_batch"].str);
125 			})
126 			.except((Exception e) {
127 				if (log) log("Sync error: " ~ e.msg);
128 				setTimeout(&sync, 10.seconds, since);
129 			});
130 	}
131 
132 public:
133 	Logger log;
134 
135 	this(string host, string clientAccessToken)
136 	{
137 		this.host = host;
138 		this.clientAccessToken = clientAccessToken;
139 	}
140 
141 	Promise!EventId send(RoomId roomId, MessageEventType eventType, RoomMessage roomMessage)
142 	{
143 		auto txnId = randomString();
144 		return send("/_matrix/client/r0/rooms/" ~ roomId.value ~ "/send/" ~ eventType ~ "/" ~ txnId, "PUT", roomMessage.fragment)
145 			.then((JSONValue response)
146 			{
147 				return response["event_id"].str.EventId;
148 			})
149 		;
150 	}
151 
152 	void subscribe(void delegate(JSONValue) subscription)
153 	{
154 		if (!subscriptions)
155 			sync();
156 		subscriptions ~= subscription;
157 	}
158 }