1 /// Main module for slack-d
2 module slack;
3 
4 import std.net.curl, std.conv;
5 
6 public import std.datetime.systime;
7 public import std.json;
8 
9 /// Base Slack Web API URL.
10 package immutable slackApiUrl = "https://slack.com/api/";
11 
12 /**
13  * Contains the response from a Slack REST API call encoded as JSON.
14  * See_Also: $(D std.json)
15  */
16 struct Response {
17 	/// Returns `true` if the last API call succeeded, `false` otherwise.
18 	auto opCast(T : bool)() const { return _value["ok"].boolean; }
19 	/// Returns the JSON response as a string.
20 	auto toString() const { return toJSON(_value); }
21 	/// Assign another Response to this one.
22 	auto ref opAssign(inout Response rhs) { _value = rhs._value; return this; }
23 	/// Returns the value for the given key from the JSON response.
24 	auto opIndex(string key) const { return _value[key]; }
25 package:
26 	/// Response may only be constructed in this package.
27 	this(const char[] response) { _value = parseJSON(response); }
28 	@disable this();
29 private:
30 	JSONValue _value;
31 }
32 
33 unittest {
34 	import std.exception, core.exception;
35 	auto r = Response("");
36 	assertThrown!JSONException(to!bool(r));
37 	assert(r.toString() == "null");
38 
39 	r = Response(`{"ok":false}`);
40 	assert(r.toString() == `{"ok":false}`);
41 	assert(r["ok"].boolean == false);
42 	assert(to!bool(r) == false);
43 
44 	r = Response(`{"ok":true}`);
45 	assert(r.toString() == `{"ok":true}`);
46 	assert(r["ok"].boolean == true);
47 	assert(to!bool(r) == true);
48 
49 	auto r2 = Response(`{"ok":true}`);
50 	assert(r == r2);
51 
52 	r = r2;
53 	assert(r == r2);
54 	assert(r2["ok"].boolean == true);
55 }
56 
57 /**
58  * Holds the authorization token and the current channel for further REST API calls.
59  */
60 struct Slack {
61 	/**
62 	 * Constructs a new Slack object and stores the token and channel for later use.
63 	 * Params:
64 	 *   token = Slack BOT token to use for REST API authorization.
65 	 *   channel = Channel to use. Default is `general`.
66 	 */
67 	this(string token, string channel = "general") {
68 		_token = token;
69 		_channel = channel;
70 	}	// ctor()
71 
72 	/**
73 	 * Sends a message to a channel.
74 	 * Params:
75 	 *   msgString = Plain message string
76 	 * Returns: JSON response from REST API endpoint.
77 	 * See_Also: $(LINK https://api.slack.com/methods/chat.postMessage)
78 	 */
79 	Response postMessage(string msgString) const {
80 		string[string] data = ["token": _token, "channel": _channel, "text": msgString];
81 		auto http = HTTP();
82 		http.addRequestHeader("Content-Type", "application/x-www-form-urlencoded");
83 		return Response(post(slackApiUrl ~ "chat.postMessage", data, http));
84 	}	// postMessage()
85 
86 	/**
87 	 * Sends a message to a channel.
88 	 * Params:
89 	 *   jsonMsg = Message containing either "attachments" or "blocks" for more elaborate formatting
90 	 * Returns: JSON response from REST API endpoint.
91 	 * See_Also: $(LINK https://api.slack.com/methods/chat.postMessage)
92 	 */
93 	Response postMessage(JSONValue jsonMsg) const {
94 		string[string] data = ["token": _token, "channel": _channel];
95 		if ("attachments" in jsonMsg)
96 			data["attachments"] = toJSON(jsonMsg["attachments"]);
97 		else if ("blocks" in jsonMsg)
98 			data["blocks"] = toJSON(jsonMsg["blocks"]);
99 		else
100 			assert(0, `missing "attachment" or "blocks"`);
101 		auto http = HTTP();
102 		http.addRequestHeader("Content-Type", "application/x-www-form-urlencoded");
103 		return Response(post(slackApiUrl ~ "chat.postMessage", data, http));
104 	}	// postMessage()
105 
106 	/**
107 	 * Fetches a conversation's history of messages and events.
108 	 * Params:
109 	 *   channelId = Conversation ID to fetch history for.
110 	 *   oldest = SysTime of oldest entry to look for (default=0)
111 	 *   latest = SysTime of latest entry to look for (default=now)
112 	 * Returns: JSON response from REST API endpoint.
113 	 * See_Also: $(LINK https://api.slack.com/methods/conversations.history)
114 	 */
115 	Response conversationsHistory(string channelId, SysTime oldest = SysTime(), SysTime latest = SysTime()) const {
116 		string[string] data = ["token": _token, "channel": channelId];
117 		if (oldest != SysTime.init) data["oldest"] = to!string(oldest.toUnixTime());
118 		if (latest != SysTime.init) data["latest"] = to!string(latest.toUnixTime());
119 		auto http = HTTP();
120 		http.addRequestHeader("Content-Type", "application/x-www-form-urlencoded");
121 		return Response(post(slackApiUrl ~ "conversations.history", data, http));
122 	}
123 
124 	/**
125 	 * Lists all channels in a Slack team.
126 	 * Returns: JSON response from REST API endpoint.
127 	 * See_Also: $(LINK https://api.slack.com/methods/conversations.list)
128 	 */
129 	Response conversationsList() const {
130 		auto http = HTTP();
131 		http.addRequestHeader("Content-Type", "application/x-www-form-urlencoded");
132 		return Response(post(slackApiUrl ~ "conversations.list",
133 						["token": _token, "exclude_archived": "true"], http));
134 	}
135 
136 	/// Sets the current channel.
137 	@property void channel(string channel) { _channel = channel; }
138 	/// Returns the current channel.
139 	@property string channel() const { return _channel; }
140 
141 private:
142 	string _channel, _token;
143 }
144 
145 unittest {
146 	import std.process, std.format, std.system, core.cpuid, std.uuid;
147 
148 	auto token = environment.get("SLACK_TOKEN");
149 	assert(token !is null, "please define the SLACK_TOKEN environment variable!");
150 
151 	auto slack = Slack(token);
152 
153 	auto msg = format("%s OS: %s (%s) CPU: %s %s with %s cores (%s threads)",
154 					randomUUID(), os, endian, vendor, processor, coresPerCPU, threadsPerCPU);
155 	auto r = slack.postMessage(msg);
156 	assert(to!bool(r), to!string(r));
157 
158 	auto attachments = `{"attachments":[{
159 			"fallback": "A message with more elaborate formatting",
160 			"pretext": "` ~ msg ~ `",
161 			"title": "Don't click here!",
162 			"title_link": "https://youtu.be/dQw4w9WgXcQ",
163 			"text": "You know the rules and so do I",
164 			"color": "#7CD197",
165 			"image_url": "https://assets.amuniversal.com/086aac509ee3012f2fe600163e41dd5b"
166 			}]}`;
167 	r = slack.postMessage(parseJSON(attachments));
168 	assert(to!bool(r), to!string(r));
169 
170 	auto blocks = `{"blocks":[
171 			{
172 				"type": "header",
173 				"text": {
174 					"type": "plain_text",
175 					"text": "` ~ msg ~ `",
176 					"emoji": true
177 				}
178 			},
179 			{
180 				"type": "section",
181 				"fields": [
182 					{
183 						"type": "mrkdwn",
184 						"text": "*Type:*\nPaid Time Off"
185 					},
186 					{
187 						"type": "mrkdwn",
188 						"text": "*Created by:*\n<example.com|Fred Enriquez>"
189 					}
190 				]
191 			},
192 			{
193 				"type": "section",
194 				"fields": [
195 					{
196 						"type": "mrkdwn",
197 						"text": "*When:*\nAug 10 - Aug 13"
198 					},
199 					{
200 						"type": "mrkdwn",
201 						"text": "*Type:*\nPaid time off"
202 					}
203 				]
204 			},
205 			{
206 				"type": "section",
207 				"fields": [
208 					{
209 						"type": "mrkdwn",
210 						"text": "*Hours:*\n16.0 (2 days)"
211 					},
212 					{
213 						"type": "mrkdwn",
214 						"text": "*Remaining balance:*\n32.0 hours (4 days)"
215 					}
216 				]
217 			},
218 			{
219 				"type": "section",
220 				"text": {
221 					"type": "mrkdwn",
222 					"text": "<https://example.com|View request>"
223 				}
224 			},
225 			{
226 				"type": "divider"
227 			},
228 			{
229 				"type": "section",
230 				"text": {
231 					"type": "mrkdwn",
232 					"text": "Pick a date for the deadline."
233 				},
234 				"accessory": {
235 					"type": "datepicker",
236 					"initial_date": "1990-04-28",
237 					"placeholder": {
238 						"type": "plain_text",
239 						"text": "Select a date",
240 						"emoji": true
241 					},
242 					"action_id": "datepicker-action"
243 				}
244 			},
245 			{
246 				"type": "section",
247 				"text": {
248 					"type": "mrkdwn",
249 					"text": "Section block with a timepicker"
250 				},
251 				"accessory": {
252 					"type": "timepicker",
253 					"initial_time": "13:37",
254 					"placeholder": {
255 						"type": "plain_text",
256 						"text": "Select time",
257 						"emoji": true
258 					},
259 					"action_id": "timepicker-action"
260 				}
261 			},
262 			{
263 				"type": "section",
264 				"text": {
265 					"type": "mrkdwn",
266 					"text": "This is a section block with an accessory image."
267 				},
268 				"accessory": {
269 					"type": "image",
270 					"image_url": "https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg",
271 					"alt_text": "cute cat"
272 				}
273 			},
274 			{
275 				"type": "divider"
276 			},
277 			{
278 				"type": "image",
279 				"image_url": "https://i1.wp.com/thetempest.co/wp-content/uploads/2017/08/The-wise-words-of-Michael-Scott-Imgur-2.jpg?w=1024&ssl=1",
280 				"alt_text": "inspiration"
281 			}
282 		]}`;
283 
284 	r = slack.postMessage(parseJSON(blocks));
285 	assert(to!bool(r), to!string(r));
286 
287 	r = slack.conversationsList();
288 	assert(to!bool(r), to!string(r));
289 
290 	import std.range.primitives;
291 
292 	string channelId;
293 	foreach (channel; r["channels"].array)
294 		if (channel["name"].str == slack.channel)
295 			channelId = channel["id"].str;
296 	assert(channelId.length > 0, to!string(r));
297 
298 	import core.time : seconds;
299 	r = slack.conversationsHistory(channelId, Clock.currTime() - seconds(10));
300 	assert(to!bool(r), to!string(r));
301 
302 	auto foundAttachments = false;
303 	auto foundBlocks = false;
304 	auto foundPlain = false;
305 	foreach (message; r["messages"].array) {
306 		if ("text" in message && message["text"].str == msg)
307 			foundPlain = true;
308 		if ("attachments" in message && message["attachments"][0]["pretext"].str == msg)
309 			foundAttachments = true;
310 		if ("blocks" in message && "text" in message["blocks"][0] && message["blocks"][0]["text"]["text"].str == msg)
311 			foundBlocks = true;
312 	}
313 	assert(foundPlain, "did not find plain message in history");
314 	assert(foundBlocks, "did not find blocks message in history");
315 	assert(foundAttachments, "did not find attachments message in history");
316 
317 	slack.channel = "some_channel";
318 	assert(slack.channel == "some_channel");
319 }
320 
321 unittest {
322 	import std.process, std.format, std.system, core.cpuid, std.uuid;
323 
324 	auto token = environment.get("SLACK_TOKEN");
325 	assert(token !is null, "please define the SLACK_TOKEN environment variable!");
326 
327 	auto slack = Slack(token);
328 
329 	auto msg = `this&that \\[@\\] an!// '; < ? >> , <~ ~ @ # \\} \\{ * & % $ ! >~<`;
330 	auto r = slack.postMessage(msg);
331 	assert(to!bool(r), to!string(r));
332 
333 	auto attachments = `{"attachments":[{
334 			"fallback": "` ~ msg ~ `",
335 			"pretext": "` ~ msg ~ `",
336 			"text": "` ~ msg ~ `",
337 			"color": "#7CD197"
338 			}]}`;
339 	r = slack.postMessage(parseJSON(attachments));
340 	assert(to!bool(r), to!string(r));
341 }