JavaScript: A Notion/Slack Integration for Standups

victoriaslocum

Victoria Slocum

Posted on September 14, 2021

JavaScript: A Notion/Slack Integration for Standups

How I made a Notion/Slack integration for Standups

Part 2: JavaScript, because Zapier is expensive 😢


Background:

One of our favorite channels in Slack is our #standup channel, where we post short updates when we finish a task, have a good meeting, or just have something to share about work. Its great to see what people are up to across departments and get updates in a central place.

We originally started doing standups in Notion through a database, but staying up to date with the page was difficult when the majority of our short-term communication happened through Slack. Eventually, our Notion page retired, and we moved to a purely Slack standup.

In part one of this post, I made a Notion and Slack integration for this standups channel using Zapier. Unfortunately, Zapier is expensive and the integration we made wasn't worth paying the money for. Fortunately, I am learning code and figured it would be the perfect project to take on.

I'm extremely happy with the way this turned out. I was able to create a cleaner, smoother interaction than the one I made with Zapier. It did take me awhile to code, but only due to minor complications and lack of experience. As always, I learned a ton, and am excited to share the process with you.

You can find the GitHub repository here!


The Process

Step 1: Setting up

There are three main things to set up the app:

  1. set up a Slack app with your workspace and initialize Bolt
  2. create a Notion integration using their APIs
  3. set up files
  4. get a list of Slack user IDs and Notion user IDs
  5. get the Slack to Notion translator

1. Setting up the Slack Bolt App

I would recommend following this tutorial if you get lost, but I'll also walk you through to help you get started with a Slack Bolt app.

Tokens and installing apps:

After you create an app, you'll need bot and app-level tokens with the following scopes. App-level tokens are found under the "Basic Information" tab in the side menu and bot tokens can be found under "OAuth & Permissions".

Bot token scopes

app level tokens

You'll also need to enable Socket mode and subscribe to the message.channels event.

enable socket mode

enable events

2. Setting up the Notion API

Go ahead and follow this guide to set up a new Notion API integration with your standups page (steps 1 and 2). If you don't already have a Notion page, you can make one with our template. If you do have one, make sure it has the following properties with the correct type: Person (person), created (date created), tags (multi-select), link to Slack (text), TS (text).

properties in Notion item

Feel free to change the names, but just make sure you change it in the code too.

3. Setting up the files

You can go ahead and initialize a folder for package.json and your app. I also put all my tokens into a .env folder and then added .env and node-modules to .gitignore so it wouldn't be published to my public GitHub repository.

mkdir my-standup-integration
cd my-standup-integration
npm init
Enter fullscreen mode Exit fullscreen mode

files in main folder

// add these to .env
NOTION_KEY=secret_
NOTION_DATABASE_ID=

SLACK_BOT_TOKEN=xoxb-
SLACK_SIGNING_SECRET=
SLACK_APP_TOKEN=xapp-
Enter fullscreen mode Exit fullscreen mode
// add this to .gitignore
.env
node_modules
node_modules
Enter fullscreen mode Exit fullscreen mode

In package.json:

{
    "name": "notion-slack-integration",
    "type": "module",
    "version": "1.0.0",
    "description": "",
    "main": "app.js",
    "scripts": {
        "start": "node app.js",
        "dev": "nodemon -r dotenv/config app.js"
    },
    "dependencies": {
        "@notionhq/client": "^0.1.9",
        "@slack/bolt": "^3.6.0",
        "dotenv": "^10.0.0",
        "he": "^1.2.0"
    }
}
Enter fullscreen mode Exit fullscreen mode

Once you have all of those dependencies in your package.json , you can run npm install in the terminal to download the necessary packages.

In app.js:

// Require the Bolt package (github.com/slackapi/bolt)
import pkg from "@slack/bolt";
const { App } = pkg;

// create variables for Slack Bot, App, and User tokens
const token = process.env.SLACK_BOT_TOKEN;
const appToken = process.env.SLACK_APP_TOKEN;

// create Slack app
const app = new App({
  token: token,
  appToken: appToken,
  signingSecret: process.env.SLACK_SIGNING_SECRET,
  socketMode: true,
});

// create Notion client
import { Client } from "@notionhq/client";
const notion = new Client({ auth: process.env.NOTION_KEY });

// create variable for Notion database ID
const databaseId = process.env.NOTION_DATABASE_ID;
Enter fullscreen mode Exit fullscreen mode

4. Getting a dictionary for Slack IDs to Notion IDs

You can find the tutorial for doing this here and the end result should look like this. Go ahead and add it to your app.js.

// Slack user ID to Notion user ID dictionary
const slackNotionId = {
  UT9G67J1Z: "f2ca3fc5-9ca1-46ed-be8b-fb618c56558a",
  U0185FAF1T5: "6718f0c7-f6e3-4c3a-9f65-e8344806b5b6",
  U025P5K0S0Z: "6f7ce62c-fa2e-4440-8805-72af5f937666",
  U021UR4DW5C: "8fd7689c-d795-4ae9-aa53-5846ac1569b7",
  U0224KFNYRW: "7c02e0ba-2aec-4696-a91d-ecaa01b616ce",
  U025J9SLXV3: "94f6b8b7-e8b0-4790-8265-f08e6b1d550c",
  UT9G67YFM: "6c3a6ec1-4b99-4e5c-8214-cea14fd9b142",
};
Enter fullscreen mode Exit fullscreen mode

5. Set up the Slack to Notion translator

You can find the GitHub here and the blog post here for the code.

Great! Now we're set up and we can move onto the functions.


Step 2: The Functions

There are 10 different functions that all play a role in making this app happen. Lets go through them.

1. Finding the Slack channel

This function allows us to filter out messages from any other channel by getting the conversation ID. Its an async function, and the Slack request uses the appToken. We check to see if the channel name matches the inputted name, and from that we can filter out the ID.

Outside of the function, we can make a variable for the ID to our channel, which we will use many times in other functions.

// find Slack channel
async function findConversation(name) {
  try {
    var conversationId = "";

    // get a list of conversations
    const result = await app.client.conversations.list({
      // app token
      appToken: appToken,
    });

    // check if channel name == input name
    for (const channel of result.channels) {
      if (channel.name === name) {
        conversationId = channel.id;
        break;
      }
    }

    // return found ID
    return conversationId;
  } catch (error) {
    console.error(error);
  }
}

// variable for slack channel
const standupId = await findConversation("standup");
Enter fullscreen mode Exit fullscreen mode

2. Adding a page to a Notion database

This function will allow us to add a page to the Notion database. The function takes in a title, body text, Slack user ID (which is then converted using the table defined above), a timestamp, tags, and a link to the Slack message. These inputs are properly formatted and then pushed as a page when the function is called. The function returns the URL of the notion page to be used later.

// add item to Notion database
async function addItem(title, text, userId, ts, tags, link) {
  try {
    // add tags with proper format
    const tagArray = [];
    for (const tag of tags) {
      tagArray.push({ name: tag });
    }

    // create page with correct properties and child using initialNotionItem function
    const response = await notion.pages.create({
      parent: { database_id: databaseId },
      properties: {
        Name: {
          type: "title",
          title: [
            {
              type: "text",
              text: {
                content: title,
              },
            },
          ],
        },
        Person: {
          type: "people",
          people: [
            {
              object: "user",
              id: slackNotionId[userId],
            },
          ],
        },
        TS: {
          type: "rich_text",
          rich_text: [
            {
              type: "text",
              text: {
                content: ts,
              },
            },
          ],
        },
        Tags: {
          type: "multi_select",
          multi_select: tagArray,
        },
        "Link to Slack": {
          type: "rich_text",
          rich_text: [
            {
              type: "text",
              text: {
                content: link,
              },
            },
          ],
        },
      },

      children: newNotionItem(text),
    });

    console.log(response);

    // return the url to be put in thread
    return response.url;
  } catch (error) {
    console.error(error);
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Finding a database item (based on a Slack message)

Remember that weird TS property in the Notion pages? This is how we identify what pages match the Slack message sent so we can append a thread message to the body of the Notion page. The function takes in the Slack message's thread_ts value so it can match it to a Notion property using a filter.

The function will return an ID of the page.

// find database item based on the threadts value from Slack and property from Notion
async function findDatabaseItem(threadTs) {
  try {
    // find Notion items with the correct threadts property
    const response = await notion.databases.query({
      database_id: databaseId,
      filter: {
        property: "TS",
        text: {
          contains: threadTs,
        },
      },
    });

    // return the ID of the page
    return response.results[0].id;
  } catch (error) {
    console.error(error);
  }
}
Enter fullscreen mode Exit fullscreen mode

4. Append text to an existing Notion page

The newNotionItem() function given by the Slack-Notion translator allows us to have a properly formatted body by just inputting some text and the Slack user ID of the author. The block_id is actually just the Notion page ID, which we found using the last function.

// append a body to a Notion page
async function addBody(id, text, userId) {
  try {
    // use ID of page and newNotionItem function for formatting
    const response = await notion.blocks.children.append({
      block_id: id,
      children: newNotionItem(text, userId),
    });
  } catch (error) {
    console.error(error);
  }
}
Enter fullscreen mode Exit fullscreen mode

5. Setting the channel topic with the existing list of tags

We found it helpful to be able to easily access the current list of tags in the database through the channel topic. This function will make an easy-to-read list of tags and only update the channel topic when a new tag has been added.

// make the list of tags for the channel topic
async function setChannelTopic(currentTag) {
  try {
    // get database and then list of tags in database
    const response = await notion.databases.retrieve({
      database_id: databaseId,
    });
    const tags = response.properties.Tags.multi_select.options;

    // make a list of the current tags in the database
    var topic = "Current tags are: ";
    tags.forEach((tag) => {
      topic += tag.name + ", ";
    });

    // set variable for reset channel topic
    var restart = false;

    // for each tag in list of tags presented in the Slack message
    currentTag.forEach((tag) => {
      // if the tag is not found add to list and set restart to true
      if (topic.search(tag) == -1) {
        topic += tag + ", ";
        restart = true;
      }
    });

    // get rid of last ", "
    topic = topic.slice(0, -2);

    // if it should be restarted, set the channel topic again
    if (restart == true) {
      const setTopic = await app.client.conversations.setTopic({
        token: token,
        channel: standupId,
        topic: topic,
      });
    }
  } catch (error) {
    console.error(error);
  }
}
Enter fullscreen mode Exit fullscreen mode

6. Reply to the Slack message with the Notion link in thread

We also found it helpful for the Bot to reply to the Slack message with a link to the created Notion page in the thread. This function takes in the channel ID, thread TS of the message, and the link to the Notion page and then replies to the message when called.

// reply to the Slack message with the Notion link
async function replyMessage(id, ts, link) {
  try {
    const result = await app.client.chat.postMessage({
      // bot token
      token: token,
      channel: id,
      thread_ts: ts,
      text: link,
    });
    return;
  } catch (error) {
    console.error(error);
  }
}
Enter fullscreen mode Exit fullscreen mode

7. Find the name of a user (instead of their ID)

For titles, it's necessary to find the name of a user, because you can't tag in a title and you don't want a weird ID to show up. This function takes in a user ID and outputs their display name.

// find the Slack username of the user using the Slack ID
async function findUserName(user) {
  try {
    const result = await app.client.users.profile.get({
      // bot token and Slack user ID
      token: token,
      user: user,
    });
    return result.profile.display_name;
  } catch (error) {
    console.error(error);
  }
}
Enter fullscreen mode Exit fullscreen mode

8. Get the tags from the message

This was definitely one of the most difficult parts of this whole process. This function takes in text, looks for "tags: " in the text, and then returns an array of tags from that.

The first thing the function is doing is retrieving the current list of tags in the database. Then, it creates an array of the tags within the Notion database. Next, the function looks for a tag line in the item and splits that into individual items in an array.

For each of the tags it found in the Slack message, it compares them to the tags already found in the database. If there is that tag in the database, it sends the database tag to a new array in order to match capitalization. If the function doesn't find the new tag in the already existing database, it will create a new tag and put that into the array.

This function returns an array of tags.

// find the tags in the Slack message
async function findTags(text) {
  try {
    // get database and then list of tags in database
    const response = await notion.databases.retrieve({
      database_id: databaseId,
    });
    const databaseTags = response.properties.Tags.multi_select.options;

    // make a list of the current tags in the database
    var dbTagArray = [];
    databaseTags.forEach((dbtag) => {
      dbTagArray.push(dbtag.name);
    });

    var tags = [];
    // search for Tags indicator
    var index = text.toLowerCase().search("tags: ");

    // if found
    if (index != -1) {
      // bypass "tags: "
      index += 6;
      // make a list by slicing from index to end and split on first line
      const tagList = text.slice(index, text.length).split("\n")[0];

      // make array of tags based on the split value
      var slackTagArray = tagList.split(", ");

      // for each found Slack tag
      slackTagArray.forEach((stag) => {
        // set counter
        var index = 0;
        // for each Notion database tag
        dbTagArray.forEach((dbtag) => {
          if (stag.toLowerCase() == dbtag.toLowerCase()) {
            // if the tags match, push the database tag
            tags.push(dbtag);
          } else {
            // if they don't, count
            index += 1;
          }

          // if it went through all of the database items, push the Slack tag
          if (index == dbTagArray.length) {
            tags.push(stag);
          }
        });
      });
    }

    // return array of tags
    return tags;
  } catch (error) {
    console.error(error);
  }
}
Enter fullscreen mode Exit fullscreen mode

9. Make the title!

Another difficult function, it takes in the text and splits in in various ways, eliminating links and users along the way.

First, we see if there's a line split for the title and replace the emojis. Then, we'll search to see if there are any links. If there are, we will split them out of their Slack formatting and just keep the text portion. Then, if there are any users and it finds it in the user dictionary we made, it will replace that tagged user with their name. Finally, it will replace tagged channel or here with a better-formatted version.

With whatever is left, it will split based on any punctuation marks and limit the character count, and return the completed title.

// create the title for the Notion page
async function makeTitle(text) {
  // split based off of line break or emphasis punctuation
  var title = text.split(/[\n]/)[0];

  // replace the emojis
  title = replaceEmojis(title);

  // search for links
  if (title.search("http") != -1 || title.search("mailto") != -1) {
    // split title based on link indicators <link>
    var regex = new RegExp(/[\<\>]/);
    var split = title.split(regex);

    // initialize title
    title = "";

    // for each line in the split text
    split.forEach((line) => {
      if (line.search("http") != -1 || line.search("mailto") != -1) {
        // if it is the link item, split the first half off and only push the text to title
        let lineSplit = line.split("|");
        title += lineSplit[1];
      } else {
        // if it isn't, push the text to title
        title += line;
      }
    });
  }

  if (title.search("@") != -1) {
    console.log(title)
    var split = title.split(" ");

    console.log(split)
    // find all instances of users and then replace in title with their Slack user name
    // wait til this promise is completed before moving on
    await Promise.all(
      split.map(async (word) => {
        if (word.search("@") != -1) {
          const userId = word.replace("@", "");
          if (userId in slackNotionId) {
            var userName = await findUserName(userId);
            title = title.replace(word, userName);
          }
        }
      })
    );
  }

  // replace weird slack formatting with more understandable stuff
  if (title.search("!channel") != -1 || title.search("!here") != -1) {
    title = title.replace("<!channel>", "@channel");
    title = title.replace("<!here>", "@here");
  }

  // split the title based on "." and "!"
  // (can't do above because links have "." and "?" and @channel has "!")
  // and return the first item
  title = title.split(/[\.\!\?]/)[0];
  // make sure its not too long
  title = title.slice(0, 100);
  return title;
}
Enter fullscreen mode Exit fullscreen mode

10. Add tags to an already established page

If you reply in thread with tags in the proper format, it will update the Notion item with the new tags you have provided without getting rid of the old tags that were already there.

The function takes in an array of tags (created by the findTags() function) and properly formats them. Then, it combines an array of the tags that already exist and the new tags and updates the Notion item with that.

// append more tags to an already existing page
async function addTags(pageId, tags) {
  try {
    // add tags with proper format
    const tagArray = [];
    for (const tag of tags) {
      tagArray.push({ name: tag });
    }

    // get already existing tags
    const page = await notion.pages.retrieve({ page_id: pageId });
    var oldTags = page.properties.Tags.multi_select;

    // create conjoined array
    var newTags = oldTags.concat(tagArray);

    // update the Notion page with the tags
    const response = await notion.pages.update({
      page_id: pageId,
      properties: {
        Tags: {
          name: "Tags",
          type: "multi_select",
          multi_select: newTags,
        },
      },
    });
  } catch (error) {
    console.error(error);
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: In event of a message...

Yay! We've set up our functions. Now its time to tell the app what happens when someone sends a message, and make sure its picking up on the right channel.

// if a message is posted
app.event("message", async ({ event, client }) => {
  console.log(event);

  // make sure its the right channel
  if (event.channel == standupId) {

        // more stuff to come here
    }
}
Enter fullscreen mode Exit fullscreen mode

Next we have to get the tags, title, and link to Slack message. Tags and title are functions, and then we can just use the .getPermalink call and get the link.

// get the tags
var tags = await findTags(event.text);

// get the title
const title = await makeTitle(event.text);

// get the link to the Slack message
const slackLink = await app.client.chat.getPermalink({
  token: token,
  channel: event.channel,
  message_ts: event.ts,
});
Enter fullscreen mode Exit fullscreen mode

Next we're going to see if its a thread message or a parent message. Thread messages will have the property thread_ts that matches the parent ts .

1) If its a thread message:

First, we have to find the database item and get the Notion page ID. Then, we can append a body to that Notion page. If there are tags in the tag array, then we can add those tags too.

2) If its a parent message:

We'll first set the channel topic if there are any new tags, and then create a Notion item and take that returned link as the variable notionUrl. Finally, we'll reply in thread with the Notion page link.

try {
  if ("thread_ts" in event) {
    // if its a thread message, find the original Notion page and then append the Slack message
    const pageId = await findDatabaseItem(event.thread_ts);
    addBody(pageId, event.text, event.user);
    if (tags.length != 0) {
      addTags(pageId, tags);
    }
  } else {
    // if its a parent message
    // make the list of tags for the channel topic and push it if applicable
    await setChannelTopic(tags);
    // make the Notion page and push to database
    const notionUrl = await addItem(
      title,
      event.text,
      event.user,
      event.ts,
      tags,
      slackLink.permalink
    );

    // reply with the link returned by addItem
    await replyMessage(standupId, event.ts, notionUrl);
  }
} catch (error) {
  console.error(error);
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Start

All that's left is to start our app! Now it will detect a message and add the proper Notion item.

(async () => {
  // Start your app
  await app.start(process.env.PORT || 3000);
  console.log("⚡️ Bolt app is running!");
})();
Enter fullscreen mode Exit fullscreen mode

Results

Here's the resulting flow:

Slack message and bot reply

New message posted in Slack, bot replies with link

setting channel

Channel topic is set with the new tag

Notion page created

The Notion page is created!!


Conclusion

I loved doing this project and working with Slack and Notion's APIs. This turned out so much better than Zapier, which was super rewarding.

Links:

GitHub: https://github.com/victoriaslocum752/standup-integration

Website: https://victoriaslocum.com

Twitter: https://twitter.com/VictoriaSlocum3


Hope to see you around again soon! 👋

💖 💪 🙅 🚩
victoriaslocum
Victoria Slocum

Posted on September 14, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related