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 = 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 = string of body content 
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         if(content != "") 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     } else if (res.statusCode == 502) { // If a 502 is recieved, back off for a while and then retry.
142       sleep(randomBackoff());
143       return this.requestJSON(route, params, content);
144     }
145     return res;
146   }
147 
148   /**
149     Return the User object for the currently logged in user.
150   */
151   User usersMeGet() {
152     auto json = this.requestJSON(Routes.USERS_ME_GET()).ok().vibeJSON;
153     return new User(this.client, json);
154   }
155 
156   /**
157     Return a User object for a Snowflake ID.
158   */
159   User usersGet(Snowflake id) {
160     auto json = this.requestJSON(Routes.USERS_GET(id)).vibeJSON;
161     return new User(this.client, json);
162   }
163 
164   /**
165     Modifies the current users settings. Returns a User object.
166   */
167   User usersMePatch(string username, string avatar) {
168     VibeJSON data = VibeJSON(["username": VibeJSON(username), "avatar": VibeJSON(avatar)]);
169     auto json = this.requestJSON(Routes.USERS_ME_PATCH(), data).vibeJSON;
170     return new User(this.client, json);
171   }
172 
173   /**
174     Returns a list of Guild objects for the current user.
175   */
176   Guild[] usersMeGuildsList() {
177     auto json = this.requestJSON(Routes.USERS_ME_GUILDS_LIST()).ok().vibeJSON;
178     return deserializeFromJSONArray(json, v => new Guild(this.client, v));
179   }
180 
181   /**
182     Leaves a guild.
183   */
184   void usersMeGuildsLeave(Snowflake id) {
185     this.requestJSON(Routes.USERS_ME_GUILDS_LEAVE(id)).ok();
186   }
187 
188   /**
189     Returns a list of Channel objects for the current user.
190   */
191   Channel[] usersMeDMSList() {
192     auto json = this.requestJSON(Routes.USERS_ME_DMS_LIST()).ok().vibeJSON;
193     return deserializeFromJSONArray(json, v => new Channel(this.client, v));
194   }
195 
196   /**
197     Creates a new DM for a recipient (user) ID. Returns a Channel object.
198   */
199   Channel usersMeDMSCreate(Snowflake recipientID) {
200     VibeJSON payload = VibeJSON(["recipient_id": VibeJSON(recipientID)]);
201     auto json = this.requestJSON(Routes.USERS_ME_DMS_CREATE(), payload).ok().vibeJSON;
202     return new Channel(this.client, json);
203   }
204 
205   /**
206     Returns a Guild for a Snowflake ID.
207   */
208   Guild guildsGet(Snowflake id) {
209     auto json = this.requestJSON(Routes.GUILDS_GET(id)).ok().vibeJSON;
210     return new Guild(this.client, json);
211   }
212 
213   /**
214     Modifies a guild.
215   */
216   Guild guildsModify(Snowflake id, VibeJSON obj) {
217     auto json = this.requestJSON(Routes.GUILDS_MODIFY(id), obj).vibeJSON;
218     return new Guild(this.client, json);
219   }
220 
221   /**
222     Deletes a guild.
223   */
224   void guildsDelete(Snowflake id) {
225     this.requestJSON(Routes.GUILDS_DELETE(id)).ok();
226   }
227 
228   /**
229     Returns a list of channels for a Guild.
230   */
231   Channel[] guildsChannelsList(Snowflake id) {
232     auto json = this.requestJSON(Routes.GUILDS_CHANNELS_LIST(id)).ok().vibeJSON;
233     return deserializeFromJSONArray(json, v => new Channel(this.client, v));
234   }
235 
236   /**
237     Removes (kicks) a user from a Guild.
238   */
239   void guildsMembersKick(Snowflake id, Snowflake user) {
240     this.requestJSON(Routes.GUILDS_MEMBERS_KICK(id, user)).ok();
241   }
242 
243   /**
244     Bans a user from a Guild.
245   */
246   void guildMembersBan(Snowflake id, Snowflake user) {
247     this.requestJSON(Routes.GUILDS_BANS_CREATE(id, user)).ok();
248   }
249 
250   /**
251     Unbans a user from a Guild.
252   */
253   void guildMembersRemoveBan(Snowflake id, Snowflake user) {
254     this.requestJSON(Routes.GUILDS_BANS_DELETE(id, user)).ok();
255   }
256 
257   /**
258     Sends a message to a channel.
259   */
260   Message channelsMessagesCreate(Snowflake chan, inout(string) content, inout(string) nonce, inout(bool) tts, inout(MessageEmbed) embed) {
261     VibeJSON payload = VibeJSON([
262       "content": VibeJSON(content),
263       "nonce": VibeJSON(nonce),
264       "tts": VibeJSON(tts),
265     ]);
266     
267     if(embed) payload["embed"] = embed.serializeToJSON();
268 
269     // Send payload and return message object
270     auto json = this.requestJSON(Routes.CHANNELS_MESSAGES_CREATE(chan), payload).ok().vibeJSON;
271     return new Message(this.client, json);
272   }
273 
274   /**
275     Edits a message's contents.
276   */
277   Message channelsMessagesModify(Snowflake chan, Snowflake msg, inout(string) content, inout(MessageEmbed) embed) {
278     VibeJSON payload = VibeJSON(["content": VibeJSON(content)]);
279 
280     if(embed) payload["embed"] = embed.serializeToJSON();
281 
282     auto json = this.requestJSON(Routes.CHANNELS_MESSAGES_MODIFY(chan, msg), payload).ok().vibeJSON;
283     return new Message(this.client, json);
284   }
285 
286   /**
287     Deletes a message.
288   */
289   void channelsMessagesDelete(Snowflake chan, Snowflake msg) {
290     this.requestJSON(Routes.CHANNELS_MESSAGES_DELETE(chan, msg)).ok();
291   }
292 
293   /**
294     Returns an array of message IDs for a channel up to limit (max 100),
295     filter with respect to supplied messageID.
296   */
297   Message[] channelsMessagesList(Snowflake chan, uint limit = 50, MessageFilter filter = MessageFilter.BEFORE, Snowflake msg = 0){
298     enum string errorTooMany = "Only 100 messages can be returned with one call - please make mutliple calls if you need more than this.";
299     assert(limit <= 100, errorTooMany);
300 
301     if(limit > 100) throw new Exception(errorTooMany);
302 
303     string[string] params = ["limit": limit.toString];
304 
305     if(msg) params[filter] = msg.toString;
306 
307     auto json = this.requestJSON(Routes.CHANNELS_MESSAGES_LIST(chan), params).ok().vibeJSON;
308     return deserializeFromJSONArray(json, v => new Message(this.client, v));
309   }
310 
311   /**
312     Deletes messages in bulk.
313   */
314   void channelsMessagesDeleteBulk(Snowflake chan, Snowflake[] msgIDs) {
315     VibeJSON payload = VibeJSON(["messages": VibeJSON(array(map!((m) => VibeJSON(m))(msgIDs)))]);
316     this.requestJSON(Routes.CHANNELS_MESSAGES_DELETE_BULK(chan), payload).ok();
317   }
318 
319   /**
320     Returns a valid Gateway Websocket URL
321   */
322   string gatewayGet() {
323     return this.requestJSON(Routes.GATEWAY_GET()).ok().vibeJSON["url"].to!string;
324   }
325 }