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:
- set up a Slack app with your workspace and initialize Bolt
- create a Notion integration using their APIs
- set up files
- get a list of Slack user IDs and Notion user IDs
- 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".
You'll also need to enable Socket mode and subscribe to the message.channels
event.
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).
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
// add these to .env
NOTION_KEY=secret_
NOTION_DATABASE_ID=
SLACK_BOT_TOKEN=xoxb-
SLACK_SIGNING_SECRET=
SLACK_APP_TOKEN=xapp-
// add this to .gitignore
.env
node_modules
node_modules
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"
}
}
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;
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",
};
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");
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);
}
}
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);
}
}
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);
}
}
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);
}
}
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);
}
}
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);
}
}
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);
}
}
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;
}
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);
}
}
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
}
}
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,
});
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);
}
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!");
})();
Results
Here's the resulting flow:
New message posted in Slack, bot replies with link
Channel topic is set with the new tag
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! 👋
Top comments (2)
I'm receiving this error even when I have provided the token in the .env file:
'You must provide an appToken when socketMode is set to true.
Interesting