Hausmeister: Automating Slack Channel Archiving Using GitHub Actions

Frank Rosner - Feb 3 '23 - - Dev Community

Motivation

Slack is a great communication tool, offering not only chat functionality, but also audio and video calls. Threads are a good way to discuss a message / topic in greater detail, without creating noise in the channel or creating interleaving conversations that are hard to follow. However, sometimes there are topics to discuss that will take more than a couple of messages. In this case, you can also utilize temporary, public channels.

Unfortunately, Slack does not have built-in functionality for temporary channels. Adding more and more channels, your Slack client performance will eventually grind to a halt, especially on mobile. Luckily, there are apps like Channitor, which automatically archive Slack channels after a certain period of inactivity.

Channitor works great, but it misses one feature that is critical for me: I only want it to manage a subset of my channels based on the channel name. So I decided to quickly build my own Slack app "Hausmeister" that gets the job done. Hausmeister is the German word for janitor.

The remainder of this blog post will walk you through the steps required to build such an app. First, we will look at how to set up the Slack App. Then, we are going to write the app code. Finally, we will wrap the code in a GitHub action to automate the execution.

Slack App

To create a new Slack App, simply navigate to https://api.slack.com/apps, and click "Create New App". Our app will need permissions to perform actions such as joining and archiving channels. Those permissions are managed as Bot Token Scopes. For our app to work, we need the following scopes:

  • channels:history
  • channels:join
  • channels:manage
  • channels:read
  • chat:write

After you added the respective scopes, they should show up like this:

Bot Token Scopes channels:history channels:join channels:manage channels:read chat:write

Now that we have the scopes defined, it is time to install the app in your workspace. I generally recommend using a test workspace when developing your app. If you are part of a corporate workspace, your administrators might have to approve the app.

If the installation was successful, a Bot User OAuth Token should become available. You will need this later, together with the signing secret which can be obtained from the App Credentials section inside the Basic Information page of your app management UI.

Hooray! We configured everything that is required for our app. Of course, it is recommended to add some description and a profile picture so that your colleagues know what this new bot is about. Next, let's write the app code.

Source Code

Our app will be running on Node.js. Let's walk through what we need, step by step. The entire source code is available on GitHub.

To interact with the Slack APIs, we can use @slack/bolt. Additionally, we will also install parse-duration to be able to conveniently specify the maximum age of the last message in a channel before archiving it.

For maximum flexibility, we will configure the app behaviour through environment variables. We need to pass:

  • The Bot User OAuth Token (HM_SLACK_BEARER_TOKEN). This is needed to authenticate the bot with the Slack API.
  • The signing secret (HM_SLACK_SIGNING_SECRET). This is used to validate incoming requests from Slack. Our app will not receive any requests, because we do not have any slash commands or similar, but the Bolt SDK requires us to specify the signing secret.
  • A regular expression determining which channels to join and manage (HM_SLACK_SIGNING_SECRET). This is the cool part.
  • The maximum age of the latest message in a channel before it gets archived (HM_LAST_MESSAGE_MAX_AGE).
  • A toggle whether to actually archive channels (HM_ARCHIVE_CHANNELS). We can turn this off to perform a dry-run.
  • A toggle whether to send a message to the channel before archiving it (HM_SEND_MESSAGE). Sending a message to the channel, indicating why it is being archived gives more context to the members.

The following listing shows an example invocation:



HM_SLACK_BEARER_TOKEN="xoxb-not-a-real-token" \
  HM_SLACK_SIGNING_SECRET="abcdefgnotarealsecret" \
  HM_CHANNEL_REGEX='^temp-.*' \
  HM_LAST_MESSAGE_MAX_AGE='30d' \
  HM_ARCHIVE_CHANNELS='true' \
  HM_SEND_MESSAGE='true' \
  node index.js


Enter fullscreen mode Exit fullscreen mode

Note that passing the credentials via environment variables comes with certain risks. Depending on how you are deploying your application, you might want to mount secrets via files instead.

Let's get into the code! The app is so simple, we will be able to fit everything into a single index.js file. The following listing contains the entire app. It still has two function stubs, which we will implement in the following paragraphs.



const { App } = require('@slack/bolt');
const parseDuration = require('parse-duration')

const app = new App({
  token: process.env.HM_SLACK_BEARER_TOKEN,
  signingSecret: process.env.HM_SLACK_SIGNING_SECRET
});

const sendMessage = process.env.HM_SEND_MESSAGE;
const archiveChannels = process.env.HM_ARCHIVE_CHANNELS;
const channelRegex = process.env.HM_CHANNEL_REGEX;
const lastMessageMaxAge = parseDuration(process.env.HM_LAST_MESSAGE_MAX_AGE);
console.log(`Archiving channels matching ${channelRegex} with no activity for ${lastMessageMaxAge}ms`);

const listChannels = async (listOptions) => {
  // TODO
}

const processChannel = async (c) => {
  // TODO
}

(async () => {
  const listOptions = {exclude_archived: true, types: "public_channel", limit: 200}
  const channels = listChannels(listOptions);
  const matchingChannels = channels.filter(c => c.name.match(channelRegex) != null);
  console.log(`Found ${matchingChannels.length} matching channels.`);
  await Promise.all(matchingChannels.map(processChannel));
})();


Enter fullscreen mode Exit fullscreen mode

First, we import our dependencies and parse the configuration from the environment. The main part consists of one async function that we immediately invoke. This is needed because the Slack API invocations are happening asynchronously.

Inside the main function we first obtain a list of all public, non-archived channels. We then filter out all channels that do not match the provided regular expression. Finally, we go through all matching channels and process them.

Next, let's implement listChannels. Listing channels can be done via the conversations.list API. The code is slightly more complex than simply invoking the respective method via the SDK, because we need to handle pagination, in case there are more than a handful of channels.



const listChannels = async (listOptions) => {
  const channels = [];
  let result = await app.client.conversations.list(listOptions);
  result.channels.forEach(c => channels.push(c));
  while (result.response_metadata.next_cursor) {
    console.log(`Fetched ${channels.length} channels so far, but there are more to fetch.`)
    result = await app.client.conversations.list({...listOptions, cursor: result.response_metadata.next_cursor});
    result.channels.forEach(c => channels.push(c));
  }
  return channels;
}


Enter fullscreen mode Exit fullscreen mode

Now that we have a list of channels, let's implement processChannel. This function will execute the following steps:

  1. Join the channel if the bot is not already a member. This is required in order to perform other channel actions later on.
  2. Get the last message posted in the channel.
  3. If the last message is older than the maximum age, send a message to the channel (if enabled) archive the channel (if enabled).


const processChannel = async (c) => {
  const getLastMessage = async (channelId) => {
    // TODO
  }

  const now = Date.now();
  const channelName = `${c.name} (${c.id})`;
  if (c.is_channel) {
    if (!c.is_member) {
      console.log(`Joining channel ${channelName}`);
      await app.client.conversations.join({channel: c.id});
    }

    console.log(`Getting latest message from channel ${channelName}`);
    const lastMessage = getLastMessage(c.id);
    const lastMessageTs = lastMessage.ts * 1000; // we want ms precision
    const lastMessageAge = now - lastMessageTs;

    if (lastMessageAge > lastMessageMaxAge) {
      console.log(`In channel ${channelName}, the last message is ${lastMessageAge}ms old (max age = ${lastMessageMaxAge}ms)`);
      if (sendMessage === 'true') {
        console.log(`Sending message to channel ${channelName}`);
        await app.client.chat.postMessage({
          channel: c.id,
          text: `I am archiving #${c.name} because it has been inactive for a while. Please unarchive the channel and reach out to my owner if this was a mistake!`
        })
      }
      if (archiveChannels === 'true') {
        console.log(`Archiving channel ${channelName}`);
        await app.client.conversations.archive({channel: c.id});
      }
    } else {
      console.log(`Not doing anything with ${channelName}, as there is still recent activity`)
    }
  }
}


Enter fullscreen mode Exit fullscreen mode

Joining a channel can be done via conversations.join. Getting the latest message requires calling conversations.history, but there is one minor detail we need to handle, so we will implement this in a separate function. Sending a message to a channel can be done via chat.postMessage. Archiving a channel happens via conversations.archive.

When the bot joins the channel, Slack will automatically send a join notification to the channel. If we simply obtained the latest message from the channel when running the app for the first time, we would not be able to archive anything, because the bot joined the channel. To solve this problem, yet keep things simple, I decided to simply skip over the latest message if it is of type channel_join.



const getLastMessage = async (channelId) => {
  const messages = await app.client.conversations.history({channel: c.id, limit: 2});
  let lastMessage = messages.messages[0];
  if (lastMessage.subtype === 'channel_join' && messages.messages.length > 1) {
    // If the most recent message is someone joining, it might be us, so we look at the last but one message
    lastMessage = messages.messages[1]
  }
  return lastMessage;
}


Enter fullscreen mode Exit fullscreen mode

Of course this might have been a real user joining the channel, but if the only activity in a temporary channel is that one person joined, I am still okay with archiving it.

Now with the code being complete, let's build a GitHub Action workflow that will execute it on a daily basis.

GitHub Action

To set up our GitHub Action workflow, we create a new YAML file in .github/workflows with the following content:



name: Run

on:
  schedule:
  - cron: "1 0 * * *"
  workflow_dispatch:

jobs:
  run:
  runs-on: ubuntu-latest

  steps:
    - uses: actions/checkout@v3
    with:
      repository: FRosner/hausmeister
      ref: 6687ed4db20f96441c49c92777c6e2e43a893a3f
    - uses: actions/setup-node@v3
    with:
      node-version: 18
    - run: npm ci
    - env:
      HM_SLACK_BEARER_TOKEN: ${{ secrets.SLACK_BEARER_TOKEN }}
      HM_SLACK_SIGNING_SECRET: ${{ secrets.SLACK_SIGNING_SECRET }}
      HM_CHANNEL_REGEX: "^temp[-_].*"
      HM_LAST_MESSAGE_MAX_AGE: "30d"
      HM_ARCHIVE_CHANNELS: "true"
      HM_SEND_MESSAGE: "true"
    run: node index.js


Enter fullscreen mode Exit fullscreen mode

We configure on.schedule with the cron expression 1 0 * * *, which will run the workflow on a daily basis. Thanks to on.workflow_dispatch we can also trigger it manually.

The job configuration is fairly simple. First, we check out the Hausmeister repository, then we install Node.js, then install the required dependencies, and finally execute index.js. The required credentials can be added as encrypted secrets.

Once the secrets are created and the workflow definition file is committed and pushed, you can manually trigger a run or wait for the scheduled execution:

GitHub action execution

That's it! We built a simple, yet super useful Slack app with a few lines of JavaScript code and GitHub Actions.

References


Cover image built based on images from Roman Synkevych and Hostreviews.co.uk.

If you liked this post, you can support me on ko-fi.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .