Table of Contents
Introduction
This article is my entry for the Slack API Challenge[^].
Do you want to get informed about new CodeProject articles in your Slack workspace? Or about all new messages in The Insider News[^]? Or all new C# questions in Quick Answers? This application uses the CodeProject API[^] to monitor CodeProject for new content and post it in a Slack workspace.
Objectives
- The CodeProject API gets polled at regular intervals for new content: articles, questions, and messages from specified forums. Optionally, only articles and questions with certain user-specified tags get looked at. A link to the new content gets posted in the Slack workspace.
- All settings can be changed through interacting with a bot that runs in the Slack workspace. Those settings are:
- Slack channel to post updates in
- Interval to poll for new content
- Tags of articles
- Tags of questions
- Forums to check for new messages
Setting Up an App and Bot on Slack
Go to Your Apps[^] and click "Create New App". You'll be asked for an app name and the workspace for the app:
After creating your app, you'll see this page:
First, go to Permissions and select permission scopes. This application needs two scopes: chat:write:bot
and bot
.
Then, go to Bots to set up the bot user. Fill in the details and click Add Bot User.
The next step is installing the app to your workspace. Go back to Permissions and click this button:
Upon installation, you can find the tokens for your workspace. You'll see two tokens: an OAuth Access Token and a Bot User OAuth Access Token. For this application, you only need the Bot User OAuth Access Token (marked in green on the screenshot).
The bot user is going to communicate with Slack's Real-Time Messaging API but to make that work, you have to invite the bot to the channel where you want it to listen for messages:
Setting Up a Client for the CodeProject API
Making use of the CodeProject API requires registering your client on CodeProject[^]. After registering, you can get your Client ID and Client Secret. These two values are required to authenticate for the CodeProject API.
Now you're ready to run the application! (To run it, build the downloadable project first. When you run it, you'll be prompted for the necessary authentication values and a file name where the application can store some settings. As an alternative to being prompted for these values, you can store them in a JSON file as described in 'The Main method' and pass a path to this JSON file as command-line argument. When the app runs, adjust its settings to fetch new content as you desire. Post !codeproject help
in a channel where the bot listens for information about this, or see The MessageHandler
class).
The Code
This application is written in C# and runs on .NET Core. The code uses two dependencies, Newtonsoft.Json
(for dealing with the JSON that is returned by both the Slack API and the CodeProject API) and System.Net.WebSockets.Client
(for establishing a connection with Slack's real-time messaging API).
The code is organized in three namespaces: CodeProjectSlackIntegration
, CodeProjectSlackIntegration.CodeProject
(with source files in the CodeProject directory) and CodeProjectSlackIntegration.Slack
(with source files in the Slack directory). The CodeProject
sub-namespace contains the classes for communicating with the CodeProject API and for checking new content. The Slack
sub-namespace contains classes for communicating with the Slack API. The CodeProjectSlackIntegration
namespace then contains the Program
class and the classes that take care of the integration of the Slack
and CodeProject
sub-namespaces.
Communicating with the Slack API
The Api Class
Making use of the Slack API requires gathering a bot token, as explained in 'Setting up an app and bot on Slack'. The Api
class in the Slack
sub-namespace (note that this is a different Api
class than the one in the CodeProject
sub-namespace) has a method to post a message in a Slack channel using Slack's postMessage
API and a method to set up real-time messaging (for the Rtm
class that we'll discuss later). For authorization, the bot token has to be passed as Authorization
header in the format Bearer <token>
.
class Api : IDisposable
{
HttpClient client = new HttpClient();
string botToken;
public Api(string botToken)
{
this.botToken = botToken;
client.BaseAddress = new Uri("https://slack.com/");
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", botToken);
}
The class is IDisposable
because the HttpClient
can be disposed too, so we want to add a Dispose
method to Api
to take care of that.
public void Dispose()
{
client.Dispose();
}
Api
has a PostMessage
method that posts a message to a certain channel in a Slack workspace, using Slack's chat.postMessage
API. To do that, we have to POST a JSON object with channel
and text
properties to the appropriate endpoint:
public async Task PostMessage(string channel, string text)
{
string json = JsonConvert.SerializeObject(new { channel, text });
StringContent content = new StringContent(json, Encoding.UTF8, "application/json");
await client.PostAsync("api/chat.postMessage", content);
}
Note that we never have to specify what workspace we want to post the message in, only the channel. This is because the bot token is associated with a specific workspace.
The Api
class also has an RtmUrl
method. This method sets up real-time messaging and returns the URL that you have to connect to using web sockets. The web socket logic happens in the Rtm
class, but the RTM setup happens here:
public async Task<(bool, string)> RtmUrl()
{
string json = await client.GetStringAsync("api/rtm.connect?token=" + botToken);
JObject response = JObject.Parse(json);
bool ok = (bool)response["ok"];
if (!ok)
{
return (false, (string)response["error"]);
}
else
{
return (true, (string)response["url"]);
}
}
This method returns a (bool, string)
which either looks like (true, [url])
or (false, [error])
, depending on the value of the ok
property in the API response. The JObject
class is in the Newtonsoft.Json.Linq
namespace and lets us easily access a value by its property name on an object.
Using the Real-Time Messaging (RTM) API
The RTM API works with web sockets. This is what we will use the System.Net.WebSockets.Client
dependency for. Let's first take a look at the class definition and constructor:
class Rtm
{
IMessageHandler messageHandler;
CancellationToken cancellationToken;
public Rtm(IMessageHandler messageHandler, CancellationToken cancellationToken)
{
this.messageHandler = messageHandler;
this.cancellationToken = cancellationToken;
}
The cancellation token will be used to let Rtm
know when it has to stop listening for messages. IMessageHandler
is an interface
that has one method, HandleMessage
:
interface IMessageHandler
{
string HandleMessage(string text);
}
HandleMessage
takes a message as input and should return a message as reply, or null
.
The Rtm
class has one method: DoWork
. This method takes the web socket URL as parameter and constantly watches for new Slack messages and responds to them as the passed IMessageHandler
implementation specifies.
public async Task DoWork(string url)
{
First, the method uses ClientWebSocket
(from the System.Net.WebSockets
namespace from the System.Net.WebSockets.Client
package) to establish a connection with the web socket:
ClientWebSocket cws = new ClientWebSocket();
await cws.ConnectAsync(new Uri(url), cancellationToken);
Console.WriteLine("Connected to web socket.");
int messageId = 1;
The messageId
variable keeps count of the messages sent over the RTM API, because the API wants that ID to be passed for each message that gets sent. After each sent message, the ID increments by one.
Then, while no cancellation is requested, this method waits for new events from the RTM API. We use the RecieveAsync
method on the ClientWebSocket
for that. This method stores the received events in an ArraySegment<byte>
and returns a WebSocketReceiveResult
. ReceiveAsync
may not immediately receive the full websocket
message at once. Sometimes multiple receives are needed. The WebSocketReceiveResult
has an EndOfMessage
property that tells us if the websocket
message is complete. We write all partial websocket
messages onto a MemoryStream
that we will read out once EndOfMessage
is true
.
while (!cancellationToken.IsCancellationRequested)
{
ArraySegment<byte> buffer = new ArraySegment<byte>(new byte[2048]);
WebSocketReceiveResult result;
using (var ms = new MemoryStream())
{
do
{
try
{
result = await cws.ReceiveAsync(buffer, cancellationToken);
}
catch (OperationCanceledException)
{
result = new WebSocketReceiveResult(0, WebSocketMessageType.Close, true);
break;
}
ms.Write(buffer.Array, buffer.Offset, result.Count);
} while (!result.EndOfMessage && !cancellationToken.IsCancellationRequested);
The receive may be cancelled, and then it will throw an OperationCanceledException
. After the do
...while
loop, we first check if the operation did get canceled before we look at the message we received. If the operation did get canceled, we break out of the loop.
if (cancellationToken.IsCancellationRequested)
{
break;
}
The WebSocketReceiveResult
also tells us something about the message type: Binary
, Text
, or Close
. We won't receive Binary
from the RTM API, but we may receive Close
. In that case, we close the ClientWebSocket
and break out of the loop.
if (result.MessageType == WebSocketMessageType.Close)
{
await cws.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None);
return;
}
If the type is not Close
, we know it's Text
. We read the text from the MemoryStream
using a StreamReader
and parse it as JSON. This JSON contains information about a Slack event, which is not necessarily a message - for example, it could also tell that someone started typing. We only care about messages though. We know that an event is a message if the type
property exists in the JSON and its value is message
. When that's the case, we call the HandleMessage
method on messageHandler
and send that response, if it exists, back to Slack. We want the response to end up in the same channel as the triggering message, which we know by checking the channel
property on the JSON event. To send the response to Slack, we have to send a JSON object over the web socket with the following properties: id
(the message ID that we increment thereafter), type
(with message
as value), channel
and text
.
ms.Seek(0, SeekOrigin.Begin);
using (StreamReader reader = new StreamReader(ms, Encoding.UTF8))
{
JObject rtmEvent = JObject.Parse(await reader.ReadToEndAsync());
if (rtmEvent.ContainsKey("type") &&
(string)rtmEvent["type"] == "message")
{
string channel = (string)rtmEvent["channel"];
string message = messageHandler.HandleMessage((string)rtmEvent["text"]);
if (message != null)
{
string json = JsonConvert.SerializeObject(new
{ id = messageId, type = "message", channel, text = message });
messageId++;
byte[] bytes = Encoding.UTF8.GetBytes(json);
await cws.SendAsync(new ArraySegment<byte>(bytes),
WebSocketMessageType.Text, true, CancellationToken.None);
}
}
}
After handling a message, it's time to go back to the beginning of the loop and wait for the next one. Outside of the while
loop, we dispose the ClientWebSocket
. This happens in two cases: when cancellation is requested, and when the web socket closes.
}
}
cws.Dispose();
Console.WriteLine("Web socket disposed.");
Communicating with the CodeProject API
ClientSettings and Api
To authenticate, we need the Client ID and Client Secret, as discussed before. In the code, we store these values in the ClientSettings
class.
namespace CodeProjectSlackIntegration.CodeProject
{
class ClientSettings
{
public string ClientId
{
get;
private set;
}
public string ClientSecret
{
get;
private set;
}
public ClientSettings(string clientId, string clientSecret)
{
ClientId = clientId;
ClientSecret = clientSecret;
}
}
}
The Api
class in the CodeProject
sub-namespace contains the methods to make web requests to the CodeProject API. We use the HttpClient
class (from System.Net.Http
) for the HTTP requests. The class definition and constructor looks like this:
class Api : IDisposable
{
HttpClient client;
ClientSettings settings;
public Api(ClientSettings settings)
{
this.settings = settings;
client = new HttpClient();
client.BaseAddress = new Uri("https://api.codeproject.com/");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
}
(Note that this is a different Api
class than in the Slack
sub-namespace.)
Here, the HttpClient
gets constructed. The base address gets set to https://api.codeproject.com/
to avoid having to repeat this in the methods that perform the requests, and the Accept
header gets set to application/json
, because that's what we want to receive from the API. We make the class derive from IDisposable
because HttpClient
is disposable, so we want to implement a Dispose
method on the class to dispose the HttpClient
:
public void Dispose()
{
client.Dispose();
}
Before we can send requests to the API, we first have to authenticate. We pass the Client ID and Client Secret to /Token
which returns an authentication token. This token must be put in the Authorization
header of future requests, in the form of Bearer <token>
. This happens in the Authenticate
method:
public async Task Authenticate()
{
string data = string.Format
("grant_type=client_credentials&client_id={0}&client_secret={1}",
Uri.EscapeDataString(settings.ClientId), Uri.EscapeDataString(settings.ClientSecret));
HttpResponseMessage response = await client.PostAsync("/Token", new StringContent(data));
string json = await response.Content.ReadAsStringAsync();
string token = (string)JObject.Parse(json)["access_token"];
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
The access token is stored in the access_token
property on the API response.
Once authenticated, the other API endpoints can be accessed. The Get
method takes care of that:
async Task<JArray> Get(string url, List<string> tags, string tagParameter)
{
string query;
if (tags == null || tags.Count < 1 || tagParameter == null)
{
query = "";
}
else
{
query = string.Format("?{0}={1}", tagParameter, string.Join
(",", tags.Select(Uri.EscapeDataString)));
}
try
{
string json = await client.GetStringAsync(url + query);
return (JArray)JObject.Parse(json)["items"];
}
catch (HttpRequestException hre)
{
Console.WriteLine("API error on " + url + query);
Console.WriteLine(hre.Message);
return null;
}
}
The url
parameter is not supposed to have the query string included. The only usage for query strings for this application, is to specify tags for articles and questions. For articles, the tags are specified with ?tags=tag1,tag2
and for questions it's ?include=tag1,tag2
. The tag list gets passed as method argument as tags
and the query string parameter name (tags
or include
) gets passed as tagParameter
. For forum messages, there are no tags and the forum ID is included in the URL, so tags
must be null
and then the query string will be empty. The API returns a JSON object with a pagination
and items
property. We are interested in items
, which is an array, and return it as a JArray
.
For convenience, we add a GetArticles
, GetForumMessages
, and GetQuestions
method, so the responsibility of calling Get
correctly lies in the Api
class:
public async Task<JArray> GetArticles(List<string> tags)
{
return await Get("v1/Articles", tags, "tags");
}
public async Task<JArray> GetForumMessages(int forumId)
{
return await Get(string.Format("v1/Forum/{0}/Threads", forumId), null, null);
}
public async Task<JArray> GetQuestions(List<string> includeTags)
{
return await Get("v1/Questions/new", includeTags, "include");
}
Lastly, the Api
class exposes a static
ParseApiDateTime
method which takes care of correct parsing of timestamps in the API response, in MM/dd/yyyy HH:mm:ss
format:
public static DateTime ParseApiDateTime(string s)
{
return DateTime.ParseExact(s, "MM/dd/yyyy HH:mm:ss", CultureInfo.InvariantCulture);
}
Watching New Content: Helper Classes
The actual content watching happens in the ContentWatcher
class. However, this class depends on a few helper classes that we will discuss first here. The first one is the ContentSettings
class and its related classes. Those classes hold the settings to be used by ContentWatcher
, that is for example, should articles be watched? If so, what tags? Should forums be watched? And so on.
[Serializable]
class ContentSettings
{
public ArticleSettings Articles { get; set; }
public ForumSettings Forums { get; set; }
public QaSettings Qa { get; set; }
}
[Serializable]
class ArticleSettings
{
public bool Enabled { get; set; }
public List<string> Tags { get; set; }
}
[Serializable]
class ForumSettings
{
public bool Enabled { get; set; }
public List<int> Forums { get; set; }
}
[Serializable]
class QaSettings
{
public bool Enabled { get; set; }
public List<string> Tags { get; set; }
}
The classes are Serializable
because they will be read from, and written to, a JSON file.
Next, we have the ContentType
enum
and the ContentSummary
class. ContentSummary
is what gets returned by ContentWatcher
when fetching new content and stores the content type, the title, the summary and a website link.
enum ContentType
{
Article,
Message,
Question
}
class ContentSummary
{
public ContentType Type { get; private set; }
public string Title { get; private set; }
public string Summary { get; private set; }
public string Link { get; private set; }
public ContentSummary(ContentType type, JObject item)
{
Type = type;
Title = (string)item["title"];
Summary = (string)item["summary"];
string link = (string)item["websiteLink"];
if (!link.StartsWith("h"))
{
Link = "https:" + link;
}
else
{
Link = link;
}
}
The constructor extracts the necessary values from a JSON object. Regardless of the content type, the format is always the same. The link
property may be a protocol-relative URL, a URL that does not start with http://
or https://
but with //
(the purpose is that, when such a URL is linked to from the href
attribute from an HTML a
tag, the protocol gets kept - you get linked to the HTTP site if you come from an HTTP site, and linked to the HTTPS site if you come from an HTTPS site). Slack does not support protocol-relative URLs as links, so we manually prepend https:
if it is one.
The ContentSummary
class has a ToSlackMessage
method that formats the summary for posting it to Slack:
public string ToSlackMessage()
{
if (Type == ContentType.Article)
{
return string.Format("New/updated article: <{0}|{1}>\r\n>>> {2}",
Link,
Title.Replace("&", "&").
Replace("<", "<").Replace(">", ">"),
Summary.Replace("&", "&").
Replace("<", "<").Replace(">", ">"));
}
else
{
return string.Format("New {0}: <{1}|{2}>",
Type.ToString().ToLowerInvariant(),
Link,
Title.Replace("&", "&").
Replace("<", "<").Replace(">", ">"));
}
}
The format is different for articles than for messages and questions. For messages and questions, the summary is left out. It's less useful for these types and often contains HTML which does not get rendered by Slack, making it look very messy. The format <url|title>
tells Slack to create a link with the title as display text that links to the URL. For the title and the summary, we replace &
, <
and >
by their HTML entities, as demanded by the Slack API.
Watching New Content
Now we're done with the helper classes, we can look at the ContentWatcher
class where the actual work happens. Calling the FetchNewContent
method on this class will return all new content - 'new' in this context means "new since the last time you called this method".
Okay, I lied when I said we're done with the helper classes. ContentWatcher
has a nested Result
class:
class ContentWatcher
{
public class Result
{
public bool AllSuccessful { get; private set; }
public List<ContentSummary> Content { get; private set; }
public Result(bool allSuccessful, List<ContentSummary> content)
{
AllSuccessful = allSuccessful;
Content = content;
}
}
I already mentioned FetchNewContent
- Result
is the type of which an instance will be returned by this method. That way, the caller learns if all API requests (articles, questions, each forum) happened successfully.
Next in the class, we have some member declarations and the constructor:
Api api;
ContentSettings settings;
string latestArticleId = null;
string latestQuestionId = null;
Dictionary<int, string> latestMessageIds = new Dictionary<int, string>();
public ContentWatcher(Api api, ContentSettings settings)
{
this.api = api;
this.settings = settings;
}
Because FetchNewContent
must only return what's new since its last call, the latest IDs at the time of that call have to be stored. For articles and questions, we can just store this in a string
. For messages, we cannot, because the latest ID depends on the forum we're looking at. That's why we use a dictionary there: the latest ID can be stored for each forum.
FetchNewContent
is split in three parts: FetchNewArticles
, FetchNewQuestions
and FetchNewMessages
. FetchNewArticles
will request the list of latest (new/updated) articles, loop through them and add them to a result list (which will be returned) until the ID of the article equals the latest ID of last call, which was stored in latestArticleId
. At the end, latestArticleId
gets updated.
async Task<Result> FetchNewArticles()
{
List<ContentSummary> result = new List<ContentSummary>();
JArray articles = await api.GetArticles(settings.Articles.Tags);
if (articles == null)
{
return new Result(false, result);
}
foreach (JObject article in articles)
{
string id = (string)article["id"];
if (id == latestArticleId)
{
break;
}
result.Insert(0, new ContentSummary(ContentType.Article, article));
}
if (articles.Count > 0)
{
latestArticleId = (string)articles[0]["id"];
}
return new Result(true, result);
}
When the result of GetArticles
is null
, we return a Result
with false
as allSuccessful
, because it indicates a failed request - if you look back at the Api.Get
method, null
was returned in the catch
block (that is, when a request fails).
FetchNewQuestions
follows exactly the same logic as FetchNewArticles
(but only for new questions here, not new/updated questions):
async Task<Result> FetchNewQuestions()
{
List<ContentSummary> result = new List<ContentSummary>();
JArray questions = await api.GetQuestions(settings.Qa.Tags);
if (questions == null)
{
return new Result(false, result);
}
foreach (JObject question in questions)
{
string id = (string)question["id"];
if (id == latestQuestionId)
{
break;
}
result.Insert(0, new ContentSummary(ContentType.Question, question));
}
if (questions.Count > 0)
{
latestQuestionId = (string)questions[0]["id"];
}
return new Result(true, result);
}
FetchNewMessages
works similarly, but with two extra difficulties:
- We have to loop through the list of all forums to watch.
- Some forums have Sticky messages, messages that are always on top of the first page. They will also always be the first item returned by the API.
FetchNewMessages
has to ignore them completely, and because the API does not tell us if a message is Sticky, we use this workaround: if the timestamp of a message is earlier than the timestamp of the last message in the API response (which will be the earliest message, not counting Stickies), then we know that this message is a Sticky and must be ignored.
async Task<Result> FetchNewMessages()
{
List<ContentSummary> result = new List<ContentSummary>();
bool allSuccess = true;
foreach (int forumId in settings.Forums.Forums)
{
JArray messages = await api.GetForumMessages(forumId);
if (messages != null && messages.Count > 0)
{
DateTime oldest = Api.ParseApiDateTime((string)messages.Last()["createdDate"]);
var noSticky = messages.Where(x => Api.ParseApiDateTime((string)x["createdDate"]) >= oldest);
foreach (JObject message in noSticky)
{
string id = (string)message["id"];
Console.WriteLine(id);
if (latestMessageIds.ContainsKey(forumId) && id == latestMessageIds[forumId])
{
break;
}
result.Insert(0, new ContentSummary(ContentType.Message, message));
}
latestMessageIds[forumId] = (string)noSticky.First()["id"];
}
else
{
allSuccess = false;
}
}
return new Result(allSuccess, result);
}
In all of these methods, we have used result.Insert(0, ...)
instead of result.Add(...)
. This ensures that the oldest items appear first in the list, so they will get posted in the chronologically correct order in the Slack workspace.
Lastly, the FetchNewContent
method. It checks in settings
what types of content it has to fetch and does so accordingly.
public async Task<Result> FetchNewContent()
{
bool allSuccess = true;
List<ContentSummary> result = new List<ContentSummary>();
if (settings.Articles.Enabled)
{
Result newArticles = await FetchNewArticles();
allSuccess &= newArticles.AllSuccessful;
result.AddRange(newArticles.Content);
}
if (settings.Qa.Enabled)
{
Result newQuestions = await FetchNewQuestions();
allSuccess &= newQuestions.AllSuccessful;
result.AddRange(newQuestions.Content);
}
if (settings.Forums.Enabled)
{
Result newMessages = await FetchNewMessages();
allSuccess &= newMessages.AllSuccessful;
result.AddRange(newMessages.Content);
}
return new Result(allSuccess, result);
}
Integration
The Settings Class
We've already seen ContentSettings
, but that class merely contained the settings for ContentWatcher
. There are still two other settings needed for the integration: the Slack channel to post in, and the interval that the content watcher has to wait before fetching new content. This class also has a LoadFromFile
static
method and a SaveToFile
method to load/save the settings as a JSON file.
[Serializable]
class Settings
{
public CodeProject.ContentSettings Content { get; set; }
public int RefreshIntervalSeconds { get; set; }
public string Channel { get; set; }
public static Settings LoadFromFile(string path)
{
return JsonConvert.DeserializeObject<Settings>(File.ReadAllText(path));
}
public void SaveToFile(string path)
{
File.WriteAllText(path, JsonConvert.SerializeObject(this));
}
When you run the application for the first time, you won't have a settings file yet. Then the application will go with the default settings. What are those? They are defined in the static
Default
property on Settings
:
public static Settings Default
{
get
{
Settings settings = new Settings();
settings.RefreshIntervalSeconds = 120;
settings.Channel = "#general";
settings.Content = new CodeProject.ContentSettings();
settings.Content.Articles = new CodeProject.ArticleSettings();
settings.Content.Forums = new CodeProject.ForumSettings();
settings.Content.Qa = new CodeProject.QaSettings();
settings.Content.Articles.Enabled = false;
settings.Content.Articles.Tags = new List<string>();
settings.Content.Forums.Enabled = false;
settings.Content.Forums.Forums = new List<int>();
settings.Content.Qa.Enabled = false;
settings.Content.Qa.Tags = new List<string>();
return settings;
}
}
By default, all content types are disabled. No article tags, question tags, and forum IDs are set. New content would be fetched every 2 minutes and posted in the #general
channel of your Slack workspace.
The MessageHandler Class
We now have separate code to deal with the Slack API and with the CodeProject API. Now, it's time to write the code that integrates the two. First, we look at the MessageHandler
class, which is an implementation of the IMessageHandler
interface. This class, and the HandleMessage
method in particular, deals with messages posted in Slack channels that the bot user joined. The message handler will treat all messages of the format !codeproject command argument1 argument2 ...
as commands. Slack provides "slash commands" itself but this application isn't using those, because these slash commands send an HTTP request to a server specified by you, so this would require having a web server or allowing external incoming connections to your computer. Defining an own command format avoids that requirement, you just need to keep the application running.
Here is a list of implemented commands:
help
- shows command help overview
- view current settings interval <seconds>
- set interval for checking for new content channel <channel>
- set channel to post new content in. Example: !codeproject channel general
(leave out the #) articles/forums/questions enable/disable
- enables or disables content checking for articles, forums, or questions. Example: !codeproject articles enable
articles/questions tags add/remove <tag>
- adds or removes one tag to listen for, for articles or questions. Example: !codeproject questions tags add c#
- (When no tags are set, all tags are included.)
articles/questions tags set tag1,tag2,tag3,...
- sets all tags to listen for, for articles or questions. Example: !codeproject questions tags add set c,c++
articles/questions tags clear
- clears all set tags qa
is a valid synonym for questions
everywhere forums add/remove <forumId>
- adds or removes a forum ID to watch new messages for. Example (The Insider News): !codeproject forums add 1658735
- The forum ID can be found in the forum URL. In some cases, it's not there. Then click 'New Discussion' and see the
fid
parameter in the query string. - When no forum IDs are set, no messages will be retrieved.
forums clear
- clears list of forums to watch forums set id1,id2,id3,...
- sets all forum IDs to listen to at once stop
- stops the application
The class definition and constructor of MessageHandler
look like this:
class MessageHandler : Slack.IMessageHandler
{
ConcurrentQueue<Settings> settingsUpdateQueue;
string settingsFilepath;
CancellationTokenSource cancellationToken;
public MessageHandler(ConcurrentQueue<Settings> settingsUpdateQueue,
string settingsFilepath, CancellationTokenSource cancellationToken)
{
this.settingsUpdateQueue = settingsUpdateQueue;
this.settingsFilepath = settingsFilepath;
this.cancellationToken = cancellationToken;
}
When a settings-updating command is invoked, the ConcurrentQueue
will be used to tell the ContentWatcher
-controlling code that the settings have been updated. The cancellation token in this context is not to tell the MessageHandler
that it has to cancel an action, but the MessageHandler
will use it to request cancellation of the other tasks when the stop
command is invoked. The command handling happens in the HandleMessage
method, which takes a string
as parameter (the Slack message), as implementation of IMessageHandler.HandleMessage
.
public string HandleMessage(string text)
{
In this method, we first check if the message is a command - that is, if the first "word" is !codeproject
. If not, we return null
, because we don't want to reply anything on Slack.
string[] words = text.Trim().Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
if (words.Length < 2 || words[0] != "!codeproject")
{
return null;
}
If the number of "words" is less than two, we can also just ignore the message, because !codeproject
without any actual command does nothing. Next, we load the current settings from the settings file:
Settings settings = Settings.LoadFromFile(settingsFilepath);
string message = null;
bool update = false;
Not all commands will make any change to the settings file. If a settings-updating command is invoked, the update
flag will be set to true
so after invoking the command, we know we have to use settingsUpdateQueue
to spread the word about the settings change. message
will be set to the reply to post to Slack. If it stays null
, the reason is that the command was unrecognized or otherwise invalid. Next in the method, we have a (nested) switch
block that acts on the valid commands, and for settings-updating commands performs input validation, updates the settings
object and sets the update
flag to true
. For the stop
command, it calls the Cancel
method on the cancellation token.
switch (words[1])
{
case "overview":
StringBuilder overviewBuilder = new StringBuilder();
overviewBuilder.AppendLine("Settings overview:");
overviewBuilder.AppendFormat("Articles enabled: {0}. Tags: {1}\r\n",
settings.Content.Articles.Enabled, string.Join(", ", settings.Content.Articles.Tags));
overviewBuilder.AppendFormat("Messages enabled: {0}. Forums: {1}\r\n",
settings.Content.Forums.Enabled, string.Join(", ", settings.Content.Forums.Forums));
overviewBuilder.AppendFormat("Questions enabled: {0}. Tags: {1}\r\n",
settings.Content.Qa.Enabled, string.Join(", ", settings.Content.Qa.Tags));
overviewBuilder.AppendFormat("Refresh interval: {0} seconds\r\n",
settings.RefreshIntervalSeconds);
overviewBuilder.AppendFormat("Posting channel: {0}", settings.Channel);
message = overviewBuilder.ToString();
break;
case "interval":
if (words.Length < 3) break;
if (int.TryParse(words[2], out int newInterval))
{
settings.RefreshIntervalSeconds = newInterval;
message = "Interval set.";
update = true;
}
break;
case "channel":
if (words.Length < 3) break;
if (Regex.IsMatch(words[2], "^[a-zA-Z0-9_-]+$"))
{
settings.Channel = "#" + words[2];
message = "New channel set.";
update = true;
}
break;
case "articles":
if (words.Length < 3) break;
switch (words[2])
{
case "enable":
settings.Content.Articles.Enabled = true;
message = "Article notifications enabled.";
update = true;
break;
case "disable":
settings.Content.Articles.Enabled = false;
message = "Article notifications disabled.";
update = true;
break;
case "tags":
if (words.Length < 4) break;
switch (words[3])
{
case "clear":
settings.Content.Articles.Tags.Clear();
message = "Article tags cleared.";
update = true;
break;
case "set":
if (words.Length < 5) break;
settings.Content.Articles.Tags = new List<string>(words[4].Split(','));
message = "Article tags set.";
update = true;
break;
case "add":
if (words.Length < 5) break;
settings.Content.Articles.Tags.Add(words[4]);
message = "Article tag added.";
update = true;
break;
case "remove":
if (words.Length < 5) break;
settings.Content.Articles.Tags.Remove(words[4]);
message = "Article tag removed, if it was in the list.";
update = true;
break;
}
break;
}
break;
case "forums":
if (words.Length < 3) break;
switch (words[2])
{
case "enable":
settings.Content.Forums.Enabled = true;
message = "Forum notifications enabled.";
update = true;
break;
case "disable":
settings.Content.Forums.Enabled = false;
message = "Forum notifications disabled.";
update = true;
break;
case "clear":
settings.Content.Forums.Forums.Clear();
message = "Forum list cleared.";
update = true;
break;
case "add":
if (words.Length < 4) break;
if (int.TryParse(words[3], out int newForum))
{
settings.Content.Forums.Forums.Add(newForum);
message = "Forum added.";
update = true;
}
break;
case "remove":
if (words.Length < 4) break;
if (int.TryParse(words[3], out int forumToRemove))
{
settings.Content.Forums.Forums.Remove(forumToRemove);
message = "Forum removed, if it was in the list.";
update = true;
}
break;
case "set":
if (words.Length < 4 || !Regex.IsMatch(words[3], "^(\\d,?)+$")) break;
settings.Content.Forums.Forums = new List<int>(words[3].Split(new char[]
{ ',' }, StringSplitOptions.RemoveEmptyEntries).Select(int.Parse));
message = "Forums set.";
update = true;
break;
}
break;
case "questions":
case "qa":
if (words.Length < 3) break;
switch (words[2])
{
case "enable":
settings.Content.Qa.Enabled = true;
message = "Question notifications enabled.";
update = true;
break;
case "disable":
settings.Content.Qa.Enabled = false;
message = "Question notifications disabled.";
update = true;
break;
case "tags":
if (words.Length < 4) break;
switch (words[3])
{
case "clear":
settings.Content.Qa.Tags.Clear();
message = "Question tags cleared.";
update = true;
break;
case "set":
if (words.Length < 5) break;
settings.Content.Qa.Tags = new List<string>(words[4].Split(','));
message = "Question tags set.";
update = true;
break;
case "add":
if (words.Length < 5) break;
settings.Content.Qa.Tags.Add(words[4]);
message = "Question tag added.";
update = true;
break;
case "remove":
if (words.Length < 5) break;
settings.Content.Qa.Tags.Remove(words[4]);
message = "Question tag removed, if it was in the list";
update = true;
break;
}
break;
}
break;
case "help":
StringBuilder help = new StringBuilder();
help.AppendLine("To execute a command, do `!codeproject command`. Available commands:");
help.AppendLine("`help` - shows command help.");
help.AppendLine("`overview` - view current settings.");
help.AppendLine("`interval <seconds>`
- set interval for checking for new content.");
help.AppendLine("`channel <channel>`
- set channel to post new content in.");
help.AppendLine("`articles/forums/questions enable/disable`
- enables or disables content checking for articles, forums, or questions.
Example: `!codeproject forums enable`");
help.AppendLine("`articles/questions tags add/remove <tag>`
- adds or removes one tag to listen for, for articles or questions.
Example: `!codeproject questions tags add c#`");
help.AppendLine("When no tags are set, all tags are included.");
help.AppendLine("`articles/questions tags set tag1,tag2,tag3`
- sets all tags to listen for, for articles or questions, at once.
Example: `!codeproject articles tags set c#,.net`");
help.AppendLine("`articles/questions tags clear` - clears all set tags.");
help.AppendLine("`qa` is a valid synonym for `questions` everywhere.");
help.AppendLine("`forums add/remove <forumId>`
- adds or removes a forum ID to watch new messages for.
Example (The Insider News): `!codeproject forums add 1658735`");
help.AppendLine("The forum ID can be found in the forum URL.
In some cases it's not there, click 'New Discussion' and see the `fid` parameter in the query string.");
help.AppendLine("When no forum IDs are set, no messages will be retrieved.");
help.AppendLine("`forums clear` - clears list of forums to watch.");
help.AppendLine("`forums set id1,id2,id3` - sets all forum IDs to listen to at once.");
help.AppendLine("`stop` - stops the application.");
message = help.ToString();
break;
case "stop":
cancellationToken.Cancel();
return null;
}
That was a big code block, but nothing complicated goes on. We just have to handle all possible commands, and for most commands, it's just a matter of checking that the input is valid and adjusting the properties of settings
accordingly.
If the update
flag is set to true
, we have to save the updated settings to the correct file and push this update to settingsUpdateQueue
. Then we return message
, but if message
is still null
at this point, the command was invalid, so then the message is Invalid command.
:
if (update)
{
settings.SaveToFile(settingsFilepath);
settingsUpdateQueue.Enqueue(settings);
}
return message ?? "Invalid command.";
The Integration Class
We still haven't written any code that controls a ContentWatcher
or the Slack API. That's what we do in the Integration
class. Let's first take a look at the class definition and constructor:
class Integration
{
Slack.Api slack;
CodeProject.Api codeproject;
Settings settings;
CodeProject.ContentWatcher watcher;
ConcurrentQueue<Settings> settingsUpdateQueue;
string settingsFilepath;
CancellationTokenSource cancellationToken;
public Integration(string slackBotToken, CodeProject.ClientSettings cpSettings,
Settings settings, string settingsFilepath)
{
slack = new Slack.Api(slackBotToken);
codeproject = new CodeProject.Api(cpSettings);
this.settings = settings;
this.settingsFilepath = settingsFilepath;
cancellationToken = new CancellationTokenSource();
settingsUpdateQueue = new ConcurrentQueue<Settings>();
watcher = new CodeProject.ContentWatcher(codeproject, settings.Content);
}
The constructor takes the Slack bot token, a ClientSettings
instance (for the Client ID and Client Secret), a Settings
instance and a string
with the path of the settings file. The ConcurrentQueue
will later be passed to an instance of MessageHandler
; this class is the ContentWatcher
-controlling code that we spoke of in that section. The cancellation token will also be passed to the MessageHandler
and will also be used to cancel ongoing operations in this class.
The method that deals with the Slack connection, WatchSlack
, looks like this:
async Task WatchSlack()
{
while (!cancellationToken.IsCancellationRequested)
{
var (success, url_or_error) = await slack.RtmUrl();
if (!success)
{
throw new Exception("Could not connect to Slack RTM: " + url_or_error);
}
Slack.Rtm rtm = new Slack.Rtm(new MessageHandler
(settingsUpdateQueue, settingsFilepath, cancellationToken), cancellationToken.Token);
await rtm.DoWork(url_or_error);
}
}
DoWork
will keep running until cancellation is requested, or until the web socket unexpectedly closes. In case of the former, the loops gets exited. In case of the latter, the loop will ensure that the application reconnects to Slack.
Another task, the WatchCodeProject
method, controls the ContentWatcher
.
async Task WatchCodeProject()
{
await codeproject.Authenticate();
await watcher.FetchNewContent();
while (!cancellationToken.IsCancellationRequested)
{
Settings settingsUpdate = null;
while (settingsUpdateQueue.Count > 0)
{
settingsUpdateQueue.TryDequeue(out settingsUpdate);
}
if (settingsUpdate != null)
{
settings = settingsUpdate;
watcher = new CodeProject.ContentWatcher(codeproject, settings.Content);
await watcher.FetchNewContent();
}
CodeProject.ContentWatcher.Result fetched = await watcher.FetchNewContent();
if (!fetched.AllSuccessful)
{
await slack.PostMessage(settings.Channel,
"Error: not all CodeProject API requests were successful.");
}
foreach (CodeProject.ContentSummary content in fetched.Content)
{
await slack.PostMessage(settings.Channel, content.ToSlackMessage());
}
try
{
await Task.Delay(settings.RefreshIntervalSeconds * 1000, cancellationToken.Token);
}
catch (TaskCanceledException) { }
}
codeproject.Dispose();
slack.Dispose();
Console.WriteLine("API clients disposed.");
}
First, we perform the necessary API authentication. Then, we call FetchNewContent
for the first time and ignore the result, because everything will be new content at this point, and we don't want to spam a Slack channel with that. Inside the loop, we check if there is a settings update. If there is, we replace the whole content watcher and again discard the first batch. We replace the entire watcher because the fields that store the latest IDs might not at all be applicable with our new settings, so we better start anew with a fresh content watcher. Then we check for new content and post all new items into the appropriate Slack channel. If not all requests were successful, we post a notice to Slack as well - more details about the failed request can then be found on the console of the application. Lastly, we use Task.Delay
to wait before checking for new content. If cancellation gets requested, we exit the loop and dispose the API clients.
We have two separate Task
s now, but we still have to ensure that they run. We could use Task.WaitAll
to run them both, but if one of them throws an exception, the other will keep running and WaitAll
won't return (and the exception also won't be shown on the console). For this application, we'd rather exit immediately on an exception so we can immediately see on the console what went wrong. Instead of WaitAll
, we use WaitAny
and if we see that one task completed without faulting, we still wait for the other task to complete - this situation will only occur when cancellation is requested on the stop
command, and then we know that both tasks are about to complete.
public void Start()
{
Task cpTask = WatchCodeProject();
Task slackTask = WatchSlack();
Task[] tasks = new Task[] { cpTask, slackTask };
int completed = Task.WaitAny(tasks);
if (tasks[completed].IsFaulted)
{
throw tasks[completed].Exception;
}
else
{
tasks[1 - completed].Wait();
}
}
WaitAny
returns the index of the completed task. We have only two tasks, so the index of the other task can be acquired using 1 - completed
.
The Main Method
When you run the application, you have to pass the Slack bot token, the Client ID/Secret for the CodeProject API, and the file path where the settings will be read from, and saved to. You have two choices to pass these values. You run the application without command-line arguments and then the application will prompt you for the values. Or you can run the application with one command-line argument that points to a JSON file where these values are stored in this format:
{
"slack": "Slack bot token",
"clientId": "CodeProject Client ID",
"clientSecret": "CodeProject Client Secret",
"settings": "settings file path"
}
In the Main
method, we have:
static void Main(string[] args)
{
string slackBot;
string clientId;
string clientSecret;
string settingsPath;
if (args.Length == 0)
{
Console.WriteLine("Slack bot token:");
slackBot = Console.ReadLine().Trim();
Console.WriteLine("CodeProject Client ID:");
clientId = Console.ReadLine().Trim();
Console.WriteLine("CodeProject Client Secret:");
clientSecret = Console.ReadLine().Trim();
Console.WriteLine("Settings file path (will be created if non-existing):");
settingsPath = Console.ReadLine().Trim();
Console.Clear();
}
else
{
JObject startup = JObject.Parse(File.ReadAllText(args[0]));
slackBot = (string)startup["slack"];
clientId = (string)startup["clientId"];
clientSecret = (string)startup["clientSecret"];
settingsPath = (string)startup["settings"];
}
Then we read the settings from the given file, if it exists. If it does not exist, we take the default settings and store them in that file:
Settings settings;
if (File.Exists(settingsPath))
{
settings = Settings.LoadFromFile(settingsPath);
}
else
{
settings = Settings.Default;
settings.SaveToFile(settingsPath);
}
Lastly, we create an Integration
instance and let it do its job:
Integration integration = new Integration
(slackBot, new CodeProject.ClientSettings(clientId, clientSecret), settings, settingsPath);
integration.Start();
}
And we're done!