Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / productivity / team-communication / slack

Posting new CodeProject Content to a Slack Workspace

5.00/5 (3 votes)
5 Feb 2019CPOL21 min read 9.7K   85  
Using the Slack API and CodeProject API, this application monitors CodeProject for new content and posts updates in a specified Slack channel.

Image 1

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

  1. 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.
  2. 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:

Image 2

After creating your app, you'll see this page:

Image 3

First, go to Permissions and select permission scopes. This application needs two scopes: chat:write:bot and bot.

Image 4

Then, go to Bots to set up the bot user. Fill in the details and click Add Bot User.

Image 5

The next step is installing the app to your workspace. Go back to Permissions and click this button:

Image 6

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).

Image 7

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:

Image 8

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>.

C#
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.

C#
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:

C#
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:

C#
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:

C#
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:

C#
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.

C#
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:

C#
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.

C#
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.

C#
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.

C#
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.

C#
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.

C#
    }
}

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.

C#
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:

C#
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:

C#
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:

C#
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:

C#
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:

C#
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:

C#
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.

C#
[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.

C#
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:

C#
public string ToSlackMessage()
{
    if (Type == ContentType.Article)
    {
        return string.Format("New/updated article: <{0}|{1}>\r\n>>> {2}",
            Link,
            Title.Replace("&", "&amp;").
            Replace("<", "<").Replace(">", "&gt;"),
            Summary.Replace("&", "&amp;").
            Replace("<", "&lt;").Replace(">", "&gt;"));
    }
    else
    {
        return string.Format("New {0}: <{1}|{2}>",
            Type.ToString().ToLowerInvariant(),
            Link,
            Title.Replace("&", "&amp;").
            Replace("<", "&lt;").Replace(">", "&gt;"));
    }
}

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:

C#
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:

C#
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.

C#
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):

C#
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:

  1. We have to loop through the list of all forums to watch.
  2. 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.
C#
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.

C#
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.

C#
[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:

C#
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:

C#
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.

C#
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.

C#
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:

C#
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.

C#
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 &lt;seconds&gt;` 
                                 - set interval for checking for new content.");
        help.AppendLine("`channel &lt;channel&gt;` 
                                 - 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 &lt;tag&gt;` 
          - 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 &lt;forumId&gt;` 
              - 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.:

C#
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:

C#
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:

C#
async Task WatchSlack()
{
    while (!cancellationToken.IsCancellationRequested) // loop to re-connect on unexpected closures
    {
        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.

C#
async Task WatchCodeProject()
{
    await codeproject.Authenticate();
    await watcher.FetchNewContent(); // discard first batch, everything counts as new content
    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 Tasks 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.

C#
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:

C#
{
    "slack": "Slack bot token",
    "clientId": "CodeProject Client ID",
    "clientSecret": "CodeProject Client Secret",
    "settings": "settings file path"
}

In the Main method, we have:

C#
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:

C#
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:

C#
    Integration integration = new Integration
      (slackBot, new CodeProject.ClientSettings(clientId, clientSecret), settings, settingsPath);
    integration.Start();
}

And we're done!

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)