Building an updatable Slack message

Slack is fully awesome. At Wehkamp we use it for our internal communication and as a tool for our DevOps. The Slack API allows us to build even more advanced integrations. In this blog I'll explore how to use the API to create stuff like a powerful progress indicator, just by updating a Slack message:

A powerful progress indicator by updating a Slack message.
How cool is this effect?
Imagine what you could build with this.

Note from 2022

The first version of this article was written in 2018. We continue to use Slack bots in Node.js, but we've decided to use TypeScript now. We used to send text messages, but nowadays we richer messages using the Slack Block Kit. When you do, it no longer shows the (edited) in your messages.

I've update the article with the new links and code to keep it fresh.

Promise the update

The Slack Web API has two methods we can use to either create or update messages:

  • chat.postMessage - this will send a new message to the channel and give us a message identifier ts. We will store and reuse it to update the message.
  • chat.update - this will update the message that corresponds to the ts.

Let's create a function that does the creation or update:

import { ChatPostMessageArguments, ChatUpdateArguments, WebClient } from "@slack/web-api"
import { ChatPostMessageWebAPICallResult, ChatUpdateMessageWebAPICallResult, Message } from "./types"

async function sendMessage(webClient: WebClient, msg: ChatPostMessageArguments | ChatUpdateArguments) {
  if (msg.ts) {
    let args = msg as ChatUpdateArguments
    let result = await webClient.chat.update(args)
    return result as ChatUpdateMessageWebAPICallResult
  }

  msg.thread_ts = msg.thread_ts || ""
  let args = msg as ChatPostMessageArguments
  let result = await webClient.chat.postMessage(args)
  return result as ChatPostMessageWebAPICallResult
}

The function returns the message identifier as a promise. The only thing we need to do is save the message identifier and reuse it when calling the function.

Asynchronous problems

The first problem I ran into had to do with messages not arriving in the same order that I expected. Sometimes I ended up with an earlier message as end-result. And sometimes I wanted to send an update for a message that had not even been created yet. If we want to work asynchronously we need to synchronize the way we send messages.

Sequence diagram showing the message flow.

Remember: only send 1 message at the time!

We need to build a class that will handle the process of sending messages. It will store the message identifier so we can update the message. The class should also make sure that only a single message is being send at the same time.

If a message is being send and a new message comes in, the class should store that new message and send it after the first message has been sent.

But what if a 3rd message comes in? We are only interested in sending the latest message, so we should only store the last message. A pattern like this prevents useless updates / excessive use of the Slack API. Be aware: rate limiting applies to the Slack API, so you might want to consider only updating the message once a second.

Class me!

So let's look at the implementation:

import { ChatPostMessageArguments, ChatUpdateArguments, WebClient } from "@slack/web-api"
import removeMarkDown from "remove-markdown"
import { ChatPostMessageWebAPICallResult, ChatUpdateMessageWebAPICallResult, Message } from "./types"

export class UpdatableMessage {
  private message?: Message
  private nextMessage?: Message
  private sending?: Promise<string>
  private isSending = false

  constructor(
    private readonly webClient: WebClient,
    private channel: string,
    private ts: string,
    private readonly threadTs: string
  ) {}

  async waitForAllToBenSent(): Promise<string | null> {
    if (this.sending) {
      await this.sending
      await delay(500)
    }

    return this.ts
  }

  async send(msg: Message): Promise<string> {
    // don't send empty or the same message
    if (!msg || msg === this.message) {
      return this.getTs()
    }

    // when sending, add to later
    if (this.isSending) {
      this.nextMessage = msg
      await Promise.resolve(this.sending)
      return this.getTs()
    }

    // save original message for comparison
    this.message = msg

    if (isString(msg)) {
      if (msg.length >= 3000) {
        msg = {
          text: msg,
        }
      } else {
        msg = {
          blocks: [
            {
              type: "section",
              text: {
                type: "mrkdwn",
                text: msg,
              },
            },
          ],
          text: removeMarkDown(msg),
        }
      }
    }

    // combine with fields for the message update
    msg = {
      ...msg,
      ...{
        ts: this.ts,
        channel: this.channel,
        as_user: true,
        thread_ts: this.threadTs,
      },
    }

    // clear blocks from previous message is we have to
    if (!msg.blocks) {
      msg.blocks = []
    }

    this.isSending = true
    this.sending = sendMessage(this.webClient, <any>msg).then(x => {
      this.ts = this.ts || x.ts
      this.channel = this.channel || x.channel
      this.isSending = false

      const msg = this.nextMessage
      this.nextMessage = null
      return this.send(msg)
    })

    return await this.sending
  }

  getTs() {
    return this.ts
  }

  getChannel() {
    return this.channel
  }

  async delete() {
    // wait for last message to complete
    let ts = await this.waitForAllToBenSent()

    // reset any updates
    this.ts = null

    // delete this message
    await this.webClient.chat.delete({
      ts: ts,
      channel: this.channel,
    })
  }
}

By implementing a simple Boolean that checks if a message is being send, we solve most - if not all - of our asynchronous problems.

Types

We have a types.d file with the following types to harmonize messages:


import {
  Block,
  ChatPostMessageArguments,
  ChatUpdateArguments,
  KnownBlock,
  WebAPICallResult
} from "@slack/web-api"

type ChatPostMessageWebAPICallResult = WebAPICallResult & {
  channel: string
  ts: string
  message: {
    test: string
    username: string
    bot_id?: string
    attachments: [
      {
        text: string
        id: number
        fallback: string
      }
    ]
    type: string
    subtype: string
    ts: string
  }
}

type ChatUpdateMessageWebAPICallResult = WebAPICallResult & {
  channel: string
  ts: string
  text: string
}

type BlockMessage = {
  text?: string
  blocks?: (KnownBlock | Block)[]
}

type Message = BlockMessage | ChatPostMessageArguments | ChatUpdateArguments | string

type ChannelWebAPICallResult = WebAPICallResult & {
  channel: {
    name: string
  }
}

Note on Slack Block Kit

We're using the Slack Block Kit to send a text message, but turning it into a text section. The text field value is used by Slack to display a simple notification (on your PC or phone). We used the remove-markdown NPM package to strip markdown from the text.

Bot Zero

A more advanced version of the class is used by the progress script of the bot-zero project to show an example of a progress indicator. Go check it out and let us know what you think.

Lucky Luke, a gunslinger known as the "man who shoots faster than his shadow".
Async problems feel like...

Changelog

  • 2023-06-06 Updated the code to clear blocks from previous updates.
  • 2023-06-06 Provided types to make sending complex (block kit) messages easier.
  • 2022-08-18 Changed to code from JavaScript into TypeScript. The code now uses the Slack Block Kit and the Slack Web API.
expand_less