1 /**
2   Base abstractions for dealing with the Discord REST API
3 */
4 
5 module dcord.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 dcord.api,
20        dcord.info,
21        dcord.types,
22        dcord.api.routes,
23        dcord.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://discord.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 = "Discord bot with Dscord (" ~ vibeVersionString ~ "), using Vibe.d " ~ vibeVersionString;
52   }
53 
54   /**
55     Makes a HTTP request to the API (with empty body), returning an APIResponse
56 
57     Params:
58       route = route to make the request for
59   */
60   APIResponse requestJSON(CompiledRoute route) {
61     return requestJSON(route, null, "");
62   }
63 
64   /**
65     Makes a HTTP request to the API (with JSON body), returning an APIResponse
66 
67     Params:
68       route = route to make the request for
69       params = HTTP parameter hash table to pass to the URL router
70   */
71   APIResponse requestJSON(CompiledRoute route, string[string] params) {
72     return requestJSON(route, params, "");
73   }
74 
75   /**
76     Makes a HTTP request to the API (with JSON body), returning an APIResponse
77 
78     Params:
79       route = route to make the request for
80       obj = VibeJSON object for the JSON body
81   */
82   APIResponse requestJSON(CompiledRoute route, VibeJSON obj) {
83     return requestJSON(route, null, obj.toString);
84   }
85 
86   /**
87     Makes a HTTP request to the API (with string body), returning an APIResponse
88 
89     Params:
90       route = route to make the request for
91       content = string of body content 
92       params = HTTP parameter hash table to pass to the URL router
93   */
94   APIResponse requestJSON(CompiledRoute route, string[string] params, string content) {
95     // High timeout, we should never hit this
96     Duration timeout = 15.seconds;
97 
98     // Check the rate limit for the route (this may sleep)
99     if(!this.ratelimit.check(route.bucket, timeout)) {
100       throw new APIError(-1, "Request expired before rate-limit cooldown.");
101     }
102 
103     string paramString = "";  //A string containing URL encoded parameters
104 
105     //If there are parameters, URL encode them into a string for appending
106     if(params != null) {
107       if(params.length > 0) {
108         paramString = "?";
109       }
110       foreach(key; params.keys) {
111         paramString ~= urlEncode(key) ~ "=" ~ urlEncode(params[key]) ~ "&";
112       }
113       paramString = paramString[0..$-1];
114     }
115 
116     auto res = new APIResponse(requestHTTP(this.baseURL ~ route.compiled ~ paramString,
117       (scope req) {
118         req.method = route.method;
119         req.headers["Authorization"] = "Bot " ~ this.token;
120         if(content != "") req.headers["Content-Type"] = "application/json";
121         req.headers["User-Agent"] = this.userAgent;
122         req.bodyWriter.write(content);
123     }));
124     this.log.tracef("[%s] [%s] %s: \n\t%s", route.method, res.statusCode, this.baseURL ~ route.compiled, content);
125 
126     // If we returned ratelimit headers, update our ratelimit states
127     if (res.header("X-RateLimit-Limit", "") != "") {
128       this.ratelimit.update(route.bucket,
129             res.header("X-RateLimit-Remaining"),
130             res.header("X-RateLimit-Reset"),
131             res.header("Retry-After", ""));
132     }
133 
134     // We ideally should never hit 429s, but in the case we do just retry the
135     // request fully.
136     if (res.statusCode == 429) {
137       this.log.error("Request returned 429. This should not happen.");
138       return this.requestJSON(route, params, content);
139     } else if (res.statusCode == 502) { // If a 502 is recieved, back off for a while and then retry.
140       sleep(randomBackoff());
141       return this.requestJSON(route, params, content);
142     }
143     return res;
144   }
145 
146   /**
147     Return the User object for the currently logged in user.
148   */
149   User usersMeGet() {
150     auto json = this.requestJSON(Routes.USERS_ME_GET()).ok().vibeJSON;
151     return new User(this.client, json);
152   }
153 
154   /**
155     Return a User object for a Snowflake ID.
156   */
157   User usersGet(Snowflake id) {
158     auto json = this.requestJSON(Routes.USERS_GET(id)).vibeJSON;
159     return new User(this.client, json);
160   }
161 
162   /**
163     Modifies the current users settings. Returns a User object.
164   */
165   User usersMePatch(string username, string avatar) {
166     VibeJSON data = VibeJSON(["username": VibeJSON(username), "avatar": VibeJSON(avatar)]);
167     auto json = this.requestJSON(Routes.USERS_ME_PATCH(), data).vibeJSON;
168     return new User(this.client, json);
169   }
170 
171   /**
172     Returns a list of Guild objects for the current user.
173   */
174   Guild[] usersMeGuildsList() {
175     auto json = this.requestJSON(Routes.USERS_ME_GUILDS_LIST()).ok().vibeJSON;
176     return deserializeFromJSONArray(json, v => new Guild(this.client, v));
177   }
178 
179   /**
180     Leaves a guild.
181   */
182   void usersMeGuildsLeave(Snowflake id) {
183     this.requestJSON(Routes.USERS_ME_GUILDS_LEAVE(id)).ok();
184   }
185 
186   /**
187     Returns a list of Channel objects for the current user.
188   */
189   Channel[] usersMeDMSList() {
190     auto json = this.requestJSON(Routes.USERS_ME_DMS_LIST()).ok().vibeJSON;
191     return deserializeFromJSONArray(json, v => new Channel(this.client, v));
192   }
193 
194   /**
195     Creates a new DM for a recipient (user) ID. Returns a Channel object.
196   */
197   Channel usersMeDMSCreate(Snowflake recipientID) {
198     VibeJSON payload = VibeJSON(["recipient_id": VibeJSON(recipientID)]);
199     auto json = this.requestJSON(Routes.USERS_ME_DMS_CREATE(), payload).ok().vibeJSON;
200     return new Channel(this.client, json);
201   }
202 
203   /**
204     Returns a Guild for a Snowflake ID.
205   */
206   Guild guildsGet(Snowflake id) {
207     auto json = this.requestJSON(Routes.GUILDS_GET(id)).ok().vibeJSON;
208     return new Guild(this.client, json);
209   }
210 
211   /**
212     Modifies a guild.
213   */
214   Guild guildsModify(Snowflake id, VibeJSON obj) {
215     auto json = this.requestJSON(Routes.GUILDS_MODIFY(id), obj).vibeJSON;
216     return new Guild(this.client, json);
217   }
218 
219   /**
220     Deletes a guild.
221   */
222   void guildsDelete(Snowflake id) {
223     this.requestJSON(Routes.GUILDS_DELETE(id)).ok();
224   }
225 
226   /**
227     Returns a list of channels for a Guild.
228   */
229   Channel[] guildsChannelsList(Snowflake id) {
230     auto json = this.requestJSON(Routes.GUILDS_CHANNELS_LIST(id)).ok().vibeJSON;
231     return deserializeFromJSONArray(json, v => new Channel(this.client, v));
232   }
233 
234   /**
235     Removes (kicks) a user from a Guild.
236   */
237   void guildsMembersKick(Snowflake id, Snowflake user) {
238     this.requestJSON(Routes.GUILDS_MEMBERS_KICK(id, user)).ok();
239   }
240 
241   /**
242     Bans a user from a Guild.
243   */
244   void guildMembersBan(Snowflake id, Snowflake user) {
245     this.requestJSON(Routes.GUILDS_BANS_CREATE(id, user)).ok();
246   }
247 
248   /**
249     Unbans a user from a Guild.
250   */
251   void guildMembersRemoveBan(Snowflake id, Snowflake user) {
252     this.requestJSON(Routes.GUILDS_BANS_DELETE(id, user)).ok();
253   }
254 
255   /**
256     Sends a message to a channel.
257   */
258   Message channelsMessagesCreate(Snowflake chan, inout(string) content, inout(string) nonce, inout(bool) tts, inout(MessageEmbed) embed) {
259     VibeJSON payload = VibeJSON([
260       "content": VibeJSON(content),
261       "nonce": VibeJSON(nonce),
262       "tts": VibeJSON(tts),
263     ]);
264     
265     if(embed) payload["embed"] = embed.serializeToJSON();
266 
267     // Send payload and return message object
268     auto json = this.requestJSON(Routes.CHANNELS_MESSAGES_CREATE(chan), payload).ok().vibeJSON;
269     return new Message(this.client, json);
270   }
271 
272   /**
273     Edits a message's contents.
274   */
275   Message channelsMessagesModify(Snowflake chan, Snowflake msg, inout(string) content, inout(MessageEmbed) embed) {
276     VibeJSON payload = VibeJSON(["content": VibeJSON(content)]);
277 
278     if(embed) payload["embed"] = embed.serializeToJSON();
279 
280     auto json = this.requestJSON(Routes.CHANNELS_MESSAGES_MODIFY(chan, msg), payload).ok().vibeJSON;
281     return new Message(this.client, json);
282   }
283 
284   /**
285     Deletes a message.
286   */
287   void channelsMessagesDelete(Snowflake chan, Snowflake msg) {
288     this.requestJSON(Routes.CHANNELS_MESSAGES_DELETE(chan, msg)).ok();
289   }
290 
291   /**
292     Returns an array of message IDs for a channel up to limit (max 100),
293     filter with respect to supplied messageID.
294   */
295   Message[] channelsMessagesList(Snowflake chan, uint limit = 50, MessageFilter filter = MessageFilter.BEFORE, Snowflake msg = 0){
296     enum string errorTooMany = "Only 100 messages can be returned with one call - please make mutliple calls if you need more than this.";
297     assert(limit <= 100, errorTooMany);
298 
299     if(limit > 100) throw new Exception(errorTooMany);
300 
301     string[string] params = ["limit": limit.toString];
302 
303     if(msg) params[filter] = msg.toString;
304 
305     auto json = this.requestJSON(Routes.CHANNELS_MESSAGES_LIST(chan), params).ok().vibeJSON;
306     return deserializeFromJSONArray(json, v => new Message(this.client, v));
307   }
308 
309   /**
310     Deletes messages in bulk.
311   */
312   void channelsMessagesDeleteBulk(Snowflake chan, Snowflake[] msgIDs) {
313     VibeJSON payload = VibeJSON(["messages": VibeJSON(array(map!(m => VibeJSON(m))(msgIDs)))]);
314     this.requestJSON(Routes.CHANNELS_MESSAGES_DELETE_BULK(chan), payload).ok();
315   }
316 
317   /**
318     Returns a valid Gateway Websocket URL
319   */
320   string gatewayGet() {
321     return this.requestJSON(Routes.GATEWAY_GET()).ok().vibeJSON["url"].to!string;
322   }
323   /// Create a webhook.
324   void channelsCreateWebhook(Snowflake chan, string name, string avatar) {
325     VibeJSON payload = VibeJSON.emptyObject();
326     payload["name"] = name;
327     payload["avatar"] = avatar;
328     this.requestJSON(Routes.WEBHOOKS_CREATE(chan), payload).ok();
329   }
330 
331   /// Delete a webhook.
332   void deleteWebhook(Snowflake id) {
333     this.requestJSON(Routes.WEBHOOKS_DELETE(id)).ok();
334   }
335 
336   void sendWebhookMessage(Snowflake id, string token, inout(string) content, inout(string) nonce, inout(bool) tts, inout(MessageEmbed) embed) {
337     VibeJSON payload = VibeJSON([
338       "nonce": VibeJSON(nonce),
339       "tts": VibeJSON(tts),
340     ]);
341 
342     if(content !is null) {
343       payload["content"] = VibeJSON(content);
344     } else {
345       payload["content"] = VibeJSON(null);
346     }
347     
348     if(embed) { 
349       payload["embeds"] = VibeJSON.emptyArray();
350       payload["embeds"] ~= embed.serializeToJSON();
351     }
352 
353     // Send payload and return message object
354     this.requestJSON(Routes.WEBHOOKS_EXECUTE(id, token), payload).ok();
355   }
356 
357   void sendWebhookMessage(Snowflake id, string token, inout(string) content, inout(string) nonce, inout(bool) tts, inout(MessageEmbed[]) embeds) {
358     VibeJSON payload = VibeJSON([
359       "nonce": VibeJSON(nonce),
360       "tts": VibeJSON(tts),
361     ]);
362 
363     if(content !is null) {
364       payload["content"] = VibeJSON(content);
365     } else {
366       payload["content"] = VibeJSON(null);
367     }
368     
369     if(embeds) {
370       payload["embeds"] = VibeJSON.emptyArray();
371       foreach(embed; embeds) payload["embeds"] ~= embed.serializeToJSON();
372     }
373 
374     // Send payload and return message object
375     this.requestJSON(Routes.WEBHOOKS_EXECUTE(id, token), payload).ok();
376   }
377 
378   VibeJSON getWebhook(Snowflake id) {
379     return this.requestJSON(Routes.WEBHOOKS_GET(id)).ok().vibeJSON;
380   }
381 
382   void reactToMessage(Snowflake chan, Snowflake msg, string emoji) {
383     this.requestJSON(Routes.REACTIONS_CREATE(chan, msg, emoji)).ok();
384   }
385 }