1 /**
2   Base abstractions for dealing with the Discord REST API
3 */
4 
5 module dscord.api.client;
6 
7 import std.conv,
8        std.array,
9        std.format,
10        std.variant,
11        std.algorithm.iteration,
12        core.time;
13 
14 import vibe.core.core,
15        vibe.http.client,
16        vibe.stream.operations,
17        vibe.textfilter.urlencode;
18 
19 import dscord.api,
20        dscord.info,
21        dscord.types,
22        dscord.api.routes,
23        dscord.api.ratelimit;
24 
25 /**
26   How messages are returned with respect to a provided messageID.
27 */
28 enum MessageFilter : string {
29   AROUND = "around",
30   BEFORE = "before",
31   AFTER = "after"
32 }
33 
34 /**
35   APIClient is the base abstraction for interacting with the Discord API.
36 */
37 class APIClient {
38   string       baseURL = "https://discordapp.com/api";
39   string       userAgent;
40   string       token;
41   RateLimiter  ratelimit;
42   Client       client;
43   Logger       log;
44 
45   this(Client client) {
46     this.client = client;
47     this.log = client.log;
48     this.token = client.token;
49     this.ratelimit = new RateLimiter;
50 
51     this.userAgent = format("DiscordBot (%s %s) %s",
52         GITHUB_REPO, VERSION,
53         "vibe.d/" ~ vibeVersionString);
54   }
55 
56   /**
57     Makes a HTTP request to the API (with empty body), returning an APIResponse
58 
59     Params:
60       route = route to make the request for
61   */
62   APIResponse requestJSON(CompiledRoute route) {
63     return requestJSON(route, null, "");
64   }
65 
66   /**
67     Makes a HTTP request to the API (with JSON body), returning an APIResponse
68 
69     Params:
70       route = route to make the request for
71       params = HTTP parameter hash table to pass to the URL router
72   */
73   APIResponse requestJSON(CompiledRoute route, string[string] params) {
74     return requestJSON(route, params, "");
75   }
76 
77   /**
78     Makes a HTTP request to the API (with JSON body), returning an APIResponse
79 
80     Params:
81       route = route to make the request for
82       obj = VibeJSON object for the JSON body
83   */
84   APIResponse requestJSON(CompiledRoute route, VibeJSON obj) {
85     return requestJSON(route, null, obj.toString);
86   }
87 
88   /**
89     Makes a HTTP request to the API (with string body), returning an APIResponse
90 
91     Params:
92       route = route to make the request for
93       content = body content as a string
94       params = HTTP parameter hash table to pass to the URL router
95   */
96   APIResponse requestJSON(CompiledRoute route, string[string] params, string content) {
97     // High timeout, we should never hit this
98     Duration timeout = 15.seconds;
99 
100     // Check the rate limit for the route (this may sleep)
101     if (!this.ratelimit.check(route.bucket, timeout)) {
102       throw new APIError(-1, "Request expired before rate-limit cooldown.");
103     }
104 
105     string paramString = "";  //A string containing URL encoded parameters
106 
107     //If there are parameters, URL encode them into a string for appending
108     if(params != null){
109       if(params.length > 0){
110         paramString = "?";
111       }
112       foreach(key; params.keys){
113         paramString ~= urlEncode(key) ~ "=" ~ urlEncode(params[key]) ~ "&";
114       }
115       paramString = paramString[0..$-1];
116     }
117 
118     auto res = new APIResponse(requestHTTP(this.baseURL ~ route.compiled ~ paramString,
119       (scope req) {
120         req.method = route.method;
121         req.headers["Authorization"] = "Bot " ~ this.token;
122         req.headers["Content-Type"] = "application/json";
123         req.headers["User-Agent"] = this.userAgent;
124         req.bodyWriter.write(content);
125     }));
126     this.log.tracef("[%s] [%s] %s: \n\t%s", route.method, res.statusCode, this.baseURL ~ route.compiled, content);
127 
128     // If we returned ratelimit headers, update our ratelimit states
129     if (res.header("X-RateLimit-Limit", "") != "") {
130       this.ratelimit.update(route.bucket,
131             res.header("X-RateLimit-Remaining"),
132             res.header("X-RateLimit-Reset"),
133             res.header("Retry-After", ""));
134     }
135 
136     // We ideally should never hit 429s, but in the case we do just retry the
137     /// request fully.
138     if (res.statusCode == 429) {
139       this.log.error("Request returned 429. This should not happen.");
140       return this.requestJSON(route, params, content);
141     // If we got a 502, just retry after a random backoff
142     } else if (res.statusCode == 502) {
143       sleep(randomBackoff());
144       return this.requestJSON(route, params, content);
145     }
146 
147     return res;
148   }
149 
150   /**
151     Return the User object for the currently logged in user.
152   */
153   User usersMeGet() {
154     auto json = this.requestJSON(Routes.USERS_ME_GET()).ok().vibeJSON;
155     return new User(this.client, json);
156   }
157 
158   /**
159     Return a User object for a Snowflake ID.
160   */
161   User usersGet(Snowflake id) {
162     auto json = this.requestJSON(Routes.USERS_GET(id)).vibeJSON;
163     return new User(this.client, json);
164   }
165 
166   /**
167     Modifies the current users settings. Returns a User object.
168   */
169   User usersMePatch(string username, string avatar) {
170     VibeJSON data = VibeJSON(["username": VibeJSON(username), "avatar": VibeJSON(avatar)]);
171     auto json = this.requestJSON(Routes.USERS_ME_PATCH(), data).vibeJSON;
172     return new User(this.client, json);
173   }
174 
175   /**
176     Returns a list of Guild objects for the current user.
177   */
178   Guild[] usersMeGuildsList() {
179     auto json = this.requestJSON(Routes.USERS_ME_GUILDS_LIST()).ok().vibeJSON;
180     return deserializeFromJSONArray(json, v => new Guild(this.client, v));
181   }
182 
183   /**
184     Leaves a guild.
185   */
186   void usersMeGuildsLeave(Snowflake id) {
187     this.requestJSON(Routes.USERS_ME_GUILDS_LEAVE(id)).ok();
188   }
189 
190   /**
191     Returns a list of Channel objects for the current user.
192   */
193   Channel[] usersMeDMSList() {
194     auto json = this.requestJSON(Routes.USERS_ME_DMS_LIST()).ok().vibeJSON;
195     return deserializeFromJSONArray(json, v => new Channel(this.client, v));
196   }
197 
198   /**
199     Creates a new DM for a recipient (user) ID. Returns a Channel object.
200   */
201   Channel usersMeDMSCreate(Snowflake recipientID) {
202     VibeJSON payload = VibeJSON(["recipient_id": VibeJSON(recipientID)]);
203     auto json = this.requestJSON(Routes.USERS_ME_DMS_CREATE(), payload).ok().vibeJSON;
204     return new Channel(this.client, json);
205   }
206 
207   /**
208     Returns a Guild for a Snowflake ID.
209   */
210   Guild guildsGet(Snowflake id) {
211     auto json = this.requestJSON(Routes.GUILDS_GET(id)).ok().vibeJSON;
212     return new Guild(this.client, json);
213   }
214 
215   /**
216     Modifies a guild.
217   */
218   Guild guildsModify(Snowflake id, VibeJSON obj) {
219     auto json = this.requestJSON(Routes.GUILDS_MODIFY(id), obj).vibeJSON;
220     return new Guild(this.client, json);
221   }
222 
223   /**
224     Deletes a guild.
225   */
226   void guildsDelete(Snowflake id) {
227     this.requestJSON(Routes.GUILDS_DELETE(id)).ok();
228   }
229 
230   /**
231     Returns a list of channels for a Guild.
232   */
233   Channel[] guildsChannelsList(Snowflake id) {
234     auto json = this.requestJSON(Routes.GUILDS_CHANNELS_LIST(id)).ok().vibeJSON;
235     return deserializeFromJSONArray(json, v => new Channel(this.client, v));
236   }
237 
238   /**
239     Removes (kicks) a user from a Guild.
240   */
241   void guildsMembersKick(Snowflake id, Snowflake user) {
242     this.requestJSON(Routes.GUILDS_MEMBERS_KICK(id, user)).ok();
243   }
244 
245   /**
246     Sends a message to a channel.
247   */
248   Message channelsMessagesCreate(Snowflake chan, inout(string) content, inout(string) nonce, inout(bool) tts, inout(MessageEmbed) embed) {
249     VibeJSON payload = VibeJSON([
250       "content": VibeJSON(content),
251       "nonce": VibeJSON(nonce),
252       "tts": VibeJSON(tts),
253     ]);
254 
255     if (embed) {
256       payload["embed"] = embed.serializeToJSON();
257     }
258 
259     // Send payload and return message object
260     auto json = this.requestJSON(Routes.CHANNELS_MESSAGES_CREATE(chan), payload).ok().vibeJSON;
261     return new Message(this.client, json);
262   }
263 
264   /**
265     Edits a messages contents.
266   */
267   Message channelsMessagesModify(Snowflake chan, Snowflake msg, inout(string) content, inout(MessageEmbed) embed) {
268     VibeJSON payload = VibeJSON(["content": VibeJSON(content)]);
269 
270     if (embed) {
271       payload["embed"] = embed.serializeToJSON();
272     }
273 
274     auto json = this.requestJSON(Routes.CHANNELS_MESSAGES_MODIFY(chan, msg), payload).ok().vibeJSON;
275     return new Message(this.client, json);
276   }
277 
278   /**
279     Deletes a message.
280   */
281   void channelsMessagesDelete(Snowflake chan, Snowflake msg) {
282     this.requestJSON(Routes.CHANNELS_MESSAGES_DELETE(chan, msg)).ok();
283   }
284 
285   /**
286     Returns an array of message IDs for a channel up to limit (max 100),
287     filter with respect to supplied messageID.
288   */
289   Message[] channelsMessagesList(Snowflake chan, uint limit = 50, MessageFilter filter = MessageFilter.BEFORE, Snowflake msg = 0){
290     enum string errorTooMany = "The maximum number of messages that can be returned at one time is 100.";
291     assert(limit <= 100, errorTooMany);
292 
293     if(limit > 100){
294       throw new Exception(errorTooMany);
295     }
296 
297     string[string] params = ["limit":limit.toString];
298 
299     if(msg){
300       params[filter] = msg.toString;
301     }
302 
303     auto json = this.requestJSON(Routes.CHANNELS_MESSAGES_LIST(chan), params).ok().vibeJSON;
304     return deserializeFromJSONArray(json, v => new Message(this.client, v));
305   }
306 
307   /**
308     Deletes messages in bulk.
309   */
310   void channelsMessagesDeleteBulk(Snowflake chan, Snowflake[] msgIDs) {
311     VibeJSON payload = VibeJSON(["messages": VibeJSON(array(map!((m) => VibeJSON(m))(msgIDs)))]);
312     this.requestJSON(Routes.CHANNELS_MESSAGES_DELETE_BULK(chan), payload).ok();
313   }
314 
315   /**
316     Returns a valid Gateway Websocket URL
317   */
318   string gatewayGet() {
319     return this.requestJSON(Routes.GATEWAY_GET()).ok().vibeJSON["url"].to!string;
320   }
321 }