We're going to build a store for ourselves! It'll look something like this:
Along the way, we'll learn how to build a Slack app and integrate it with @hackclub/bag
, our library for interacting with bag
programmatically.
Before we get started, let's make sure we have everything we need:
The first thing we'll do is create a Slack app. Open your terminal and create a nice folder to work in:
mkdir my-store
cd my-store
We're going to run:
npm init -y
This gives us a starter boilerplate package.json
, which is where information about our installed packages live.
Let's install the packages we'll need:
npm i @slack/bolt @hackclub/bag dotenv
npm i -D nodemon
@slack/bolt
is what we'll need for our Slack app, @hackclub/bag
is what we'll use to transfer gp for items, dotenv
lets us get secrets from a file like .env
, and nodemon
just watches our file and reloads automatically every time we update it. Let's create that file now:
touch .env
Let's also create a index.js
file where all our code is going to go. I'm also going to add an entry to the scripts
section of our package.json
to run our file:
{
// ...
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "nodemon index.js" // <- This line right here!
}
// ...
}
Now every time we want to run our file and have it automatically rerun when we save it we can run npm start
in the terminal.
Next, we'll head over to the Hack Club Slack and run /bot
to create a app. It'll ask us a couple of starter questions:
Once you create your app, bag
will send you some useful info:
orpheus' store created, your app ID is 12 and your app token is 1ffdfdee-9c29-4893-b010-5927d2b940c4. (Don't share it with anyone unless they're also working on the app!) Your app can: read public inventory items.
To edit your app/request a permission level, run /bot orpheus' store.
Let's copy that down into .env:
BAG_APP_ID=<your app ID>
BAG_APP_KEY=<your app key>
Let's go over to api.slack.com and click on Your Apps > Create New App > From scratch.
We need to grab three values. The first is the signing secret, SLACK_SIGNING_SECRET
, which you can find by going to Basic info and scrolling down.
We also need a bot token, which we can grab by going to OAuth & Permissions, scrolling down to Scopes, and adding two scopes that we'll need: app_mentions:read
, which will allow us to read messages where our store is mentioned, and chat:write
, which will allow our store to send messages. When you add these, you can scroll back up and find the bot user OAuth token for SLACK_BOT_TOKEN
.
The last thing we need is an app token. We can grab that by going to Basic information and scrolling down to Generate app level token. You can name your token whatever you want.
SLACK_SIGNING_SECRET=<your signing secret>
SLACK_BOT_TOKEN=<your bot token>
SLACK_APP_TOKEN=<your app token>
The last thing we need to do is add:
ME=<your Slack ID>
This is your Slack member ID (click on your profile picture in the bottom left corner > Profile > More > Copy member ID) which will be used to transfer items from you to the customer (in exchange for some gp, of course.)
Let's start writing some code! First we'll set up the apps and make sure we can interact with our bot in Slack. In index.js
:
const { App: SlackApp } = require('@slack/bolt')
const { App: Bag } = require('@hackclub/bag')
require('dotenv/config')
const app = new SlackApp({
token: process.env.SLACK_BOT_TOKEN,
appToken: process.env.SLACK_APP_TOKEN,
signingSecret: process.env.SLACK_SIGNING_SECRET
})
let bag
const canSell = {
'Fancy Pants': 30,
'Cake': 50
}
app.event('app_mention', async props => {
console.log(canSell)
})
;(async () => {
bag = await Bag.connect({
appId: Number(process.env.BAG_APP_ID),
key: process.env.BAG_APP_KEY
})
const port = process.env.PORT || 3000
await app.start(port)
console.log(`⚡️ Bolt app is running on port ${port}!`)
})()
Run npm start
, and you should see this:
> my-store@1.0.0 start
> nodemon index.js
[nodemon] 3.1.0
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,cjs,json
[nodemon] starting `node index.js`
⚡️ Bolt app is running on port 3000!
If this code runs, we're ready to go! Notice how we connect to bag
.
Let's have it so that when the store is mentioned, it opens up a little shopping cart.
const canSell = {
'Fancy Pants': 30,
'Cake': 50
}
const showStore = async (slack, thread) => {
const app = await bag.getApp()
const identity = await bag.getIdentity({
identityId: slack
})
let blocks = [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `Hello <@${slack}>!\n\n>${app.description}\n\nHere's what's up for grabs right now:`
}
}
]
return blocks
}
app.event('app_mention', async props => {
const app = await bag.getApp()
if (app.metadata[props.context.userId] !== null) {
try {
// There's already a previous thread, delete that thread before creating a new one
await props.client.chat.delete({
...app.metadata[props.context.userId].thread
})
} catch {}
}
const { channel, ts } = await props.client.chat.postMessage({
channel: props.body.event.channel,
blocks: await showStore(props.context.userId)
})
// Add channel and thread to our metadata
await bag.updateApp({
new: {
metadata: JSON.stringify({
[props.context.userId]: {
thread: { channel, ts }
}
})
}
})
await props.client.chat.update({
channel,
ts,
blocks: await showStore(props.context.userId, { channel, ts })
})
})
When our store gets mentioned (try it!), we post a message by running showStore
, which returns a list of blocks that describe how our text is going to look. Eventually we're going to get this to show a nice little catalog of stuff, but for now it just says hello to us and gives us our app description.
Notice that we use bag.getApp()
, which is really useful for getting info about our own app. Specifically, the metadata of our app is almost like a little database for us - we can store whatever we want there. For our store, we want to store if a user has a cart open, and if so, info about that cart. If the user already has a message with the cart, we want to delete that message and repost a new message.
Now let's work on getting a catalog of stuff to show up! We're going to use bag.getInventory()
to get our inventory, and then filter it so we're only selling a certain subset of items we want to sell. Let's update showStore
to do this:
const showStore = async (slack, thread) => {
const app = await bag.getApp()
const identity = await bag.getIdentity({
identityId: slack
})
let blocks = [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `Hello <@${slack}>!\n\n>${app.description}\n\nHere's what's up for grabs right now:`
}
}
]
const inventory = (
await bag.getInventory({
identityId: process.env.ME,
available: true
})
).filter(instance => Object.keys(canSell).includes(instance.itemId))
if (!inventory.length) {
blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: 'Nothing currently. Check back later for new fares!'
}
})
return blocks
} else {
for (let instance of inventory) {
const item = await bag.getItem({
query: JSON.stringify({ name: instance.itemId })
})
let info = {
type: 'section',
text: {
type: 'mrkdwn',
text: `x1 ${item.reaction} ${instance.itemId} costs ${
canSell[instance.itemId]
} :-gp: each and there ${instance.quantity === 1 ? 'is' : 'are'} x${
instance.quantity
} currently`
}
}
if (thread)
info.accessory = {
type: 'button',
text: {
type: 'plain_text',
text: 'Add to cart'
},
style: 'primary',
value: JSON.stringify({
instance: {
name: item.name,
id: instance.id,
quantity: instance.quantity
},
thread,
slack
}),
action_id: 'add-cart'
}
blocks.push(info)
}
}
return blocks
}
Now when we mention our store, we'll get something like this:
Nice! How about we work on getting that button to work now?
Notice that we attached a action_id
to every button along with a JSON-stringified value
. Our Slack bot should listen for actions that match add-cart
, and run some code when the action gets triggered:
app.action('add-cart', async props => {
await props.ack()
const { instance, thread, slack } = JSON.parse(props.action.value)
if (slack !== props.context.userId)
return props.respond({
response_type: 'ephemeral',
replace_original: false,
text: 'Not your shopping cart, unfortunately.'
})
await props.client.views.open({
trigger_id: props.body.trigger_id,
view: {
callback_id: 'add-cart',
title: {
type: 'plain_text',
text: `Add ${instance.name} to cart`
},
submit: {
type: 'plain_text',
text: 'Add to cart'
},
type: 'modal',
private_metadata: JSON.stringify({ instance, thread, slack }),
blocks: [
{
type: 'input',
element: {
type: 'number_input',
is_decimal_allowed: false,
action_id: 'quantity',
min_value: '1',
initial_value: '1',
max_value: instance.quantity.toString()
},
label: {
type: 'plain_text',
text: 'Quantity'
}
}
]
}
})
})
Now when you click on the button, you get a modal that lets you input a quantity to add to cart! Except when you click the "Add to cart" it doesn't quite work yet. Let's fix that.
Notice that we attached a action_id
to every button along with a JSON-stringified value
. Our Slack bot should listen for actions that match add-cart
, and run some code when the action gets triggered. In this case, let's open a popup that asks the user how much they want to add to their cart:
app.view('add-cart', async props => {
await props.ack()
const { instance, thread, slack } = JSON.parse(props.view.private_metadata)
const quantity = Number(
Object.values(props.view.state.values)[0].quantity.value
)
// Check if user has enough gp
const cost = canSell[instance.name] * quantity
let gp = (
await bag.getInventory({ identityId: props.context.userId })
).filter(instance => instance.itemId === 'gp')
if (!gp.length || gp[0] < cost)
return await props.respond({
response_type: 'ephemeral',
replace_original: false,
text: "Looks like you can't spare that kind of :-gp: yet."
})
gp = gp[0]
// Check cart
const app = await bag.getApp()
if (!app.metadata[props.context.userId].id) {
// First item in cart
const cart = await bag.createTrade({
initiator: props.context.userId,
receiver: process.env.ME
})
await bag.updateTrade({
tradeId: cart.id,
identityId: props.context.userId,
add: [{ id: gp.id, quantity: cost }]
})
await bag.updateTrade({
tradeId: cart.id,
identityId: process.env.ME,
add: [{ id: instance.id, quantity }]
})
await bag.updateApp({
new: {
metadata: JSON.stringify({
[props.context.userId]: {
...app.metadata[props.context.userId],
id: cart.id
}
})
}
})
} else {
// Add item to cart
await bag.updateTrade({
tradeId: app.metadata[props.context.userId].id,
identityId: props.context.userId,
add: [{ id: gp.id, quantity: cost }]
})
await bag.updateTrade({
tradeId: app.metadata[props.context.userId].id,
identityId: process.env.ME,
add: [{ id: instance.id, quantity }]
})
}
await props.client.chat.update({
...thread,
blocks: await showStore(slack, thread)
})
})
We do a lot of fun stuff with trading here. First, we do the obvious - we check if the user has enough gp to actually buy the item. Then, we check if the user has a cart already.
If a cart (trade! a cart is ultimately a trade if you think about it) already exists, we're going to add to that cart. Otherwise, we will create a new cart and add the items there, and also log the total cost.
Let's make that cart show up to the customer now. We're going to make a few tweaks to showStore
now that are pretty important logic-wise:
const showStore = async (slack, thread) => {
const app = await bag.getApp()
let cart = undefined
if (app.metadata[slack] && app.metadata[slack].id)
cart = await bag.getTrade({
tradeId: app.metadata[slack].id
})
let blocks = [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `Hello <@${slack}>!\n\n>${app.description}\n\nHere's what's up for grabs right now:`
}
}
]
// Get all items in my inventory that are in canSell
const inventory = (
await bag.getInventory({
identityId: process.env.ME,
available: true
})
).filter(instance => Object.keys(canSell).includes(instance.itemId))
if (!inventory.length) {
if (!cart || !cart.receiverTrades.length) {
blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: 'Nothing currently. Check back tomorrow for new fares!'
}
})
return blocks
}
// User has a cart with items, but nothing is in stock
blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: 'Nothing in stock currently.'
}
})
} else {
for (let instance of inventory) {
const item = await bag.getItem({
query: JSON.stringify({ name: instance.itemId })
})
let info = {
type: 'section',
text: {
type: 'mrkdwn',
text: `x1 ${item.reaction} ${instance.itemId} costs ${
canSell[instance.itemId]
} :-gp: each and there ${instance.quantity === 1 ? 'is' : 'are'} x${
instance.quantity
} currently`
}
}
if (thread)
info.accessory = {
type: 'button',
text: {
type: 'plain_text',
text: 'Add to cart'
},
style: 'primary',
value: JSON.stringify({
instance: {
name: item.name,
id: instance.id,
quantity: instance.quantity
},
thread,
slack
}),
action_id: 'add-cart'
}
blocks.push(info)
}
}
blocks.push({
type: 'divider'
})
return blocks
}
When we have the available
flag on bag.getInventory
, we get inventory items that aren't being used in trades or crafting. This is pretty useful since we want an accurate count to show the customer and also don't want to run into the pesky Not enough :-item: item to trade
error. There's just one minor problem: some of these instances could be in our cart, and if it's empty because of that, then we can't see our cart anymore, which sounds pretty problematic. Adding a few if-else
statements takes care of this, fortunately.
Now let's add the code to show the cart:
const showStore = async (slack, thread) => {
// ...
// Get items in cart
if (!cart)
blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: 'Items in cart: nothing yet.'
}
})
else {
let cartToString = []
for (let order of cart.receiverTrades) {
const { instance } = order
const item = await bag.getItem({
query: JSON.stringify({ name: instance.itemId })
})
cartToString.push({
type: 'section',
text: {
type: 'mrkdwn',
text: `x${order.quantity} ${item.reaction} ${item.name}`
},
accessory: {
type: 'button',
text: {
type: 'plain_text',
text: 'Remove from cart'
},
style: 'danger',
value: JSON.stringify({
thread,
slack,
instance: {
name: item.name,
id: instance.id,
quantity: order.quantity
}
}),
action_id: 'remove-cart'
}
})
}
blocks.push(
{
type: 'section',
text: {
type: 'mrkdwn',
text: 'Items in cart:'
}
},
...cartToString,
{
type: 'section',
text: {
type: 'mrkdwn',
text: `Total: ${cart.initiatorTrades[0].quantity} :-gp:`
}
},
{
type: 'actions',
elements: [
{
type: 'button',
text: {
type: 'plain_text',
text: 'Cancel'
},
value: JSON.stringify({ thread, slack }),
style: 'danger',
action_id: 'cancel-checkout'
},
{
type: 'button',
text: {
type: 'plain_text',
text: 'Checkout'
},
value: JSON.stringify({ thread, slack }),
style: 'primary',
action_id: 'checkout'
}
]
}
)
}
return blocks
}
Now try adding another item to your cart, and you'll see something like this:
The last thing we're going to implement here is checking out:
app.action('checkout', async props => {
await props.ack()
const { thread, slack } = JSON.parse(props.action.value)
if (slack !== props.context.userId)
return props.respond({
response_type: 'ephemeral',
replace_original: false,
text: 'Not your shopping cart, unfortunately.'
})
const app = await bag.getApp()
// Check if user has enough gp
const cart = await bag.getTrade({
tradeId: app.metadata[props.context.userId].id
})
const gp = cart.initiatorTrades[0].quantity
const cost = cart.receiverTrades.reduce(
(acc, curr) => acc + canSell[curr.instance.itemId] * curr.quantity,
0
)
if (gp < cost)
return props.respond({
response_type: 'ephemeral',
replace_original: false,
text: "Looks like you can't spare that kind of :-gp: yet... try taking a few items off."
})
let purchased = []
for (let purchase of cart.receiverTrades) {
const item = await bag.getItem({
query: JSON.stringify({ name: purchase.instance.itemId })
})
purchased.push(`x${purchase.quantity} ${item.reaction} ${item.name}`)
}
const sale = await bag.closeTrade({
tradeId: app.metadata[props.context.userId].id
})
await bag.updateApp({
new: JSON.stringify({
[props.context.userId]: null
})
})
await props.client.chat.update({
...thread,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `Thanks <@${slack}> for stopping by! Here's what you got:\n\n${purchased.join(
'\n'
)}`
}
},
{
type: 'divider'
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: `Total: ${sale.initiatorTrades[0].quantity} :-gp:`
}
}
]
})
})
And with that, checkout should work, and the items shall be transferred! Most of the work is already done for us by bag.closeTrade
.
Let's make that Cancel checkout button work now. This is quite easy, because it turns out that bag.closeTrade
also has a extra parameter you can can attach to it, cancel
, that simply cancels the trade:
app.action('cancel-checkout', async props => {
await props.ack()
const { thread, slack } = JSON.parse(props.action.value)
const app = await bag.getApp()
await bag.closeTrade({
tradeId: app.metadata[props.context.userId].id,
cancel: true
})
await bag.updateApp({
new: JSON.stringify({
[props.context.userId]: null
})
})
await props.client.chat.delete({
...thread
})
await props.client.chat.postEphemeral({
channel: thread.channel,
user: props.context.userId,
text: 'Aww. Come by next time?'
})
})
What if we want to remove items from the cart rather than, canceling the own cart? We have those buttons after all. Let's tie an action in to listen for those button clicks and remove items.
app.action('remove-cart', async props => {
await props.ack()
const { instance, thread, slack } = JSON.parse(props.action.value)
if (slack !== props.context.userId)
return props.respond({
response_type: 'ephemeral',
replace_original: false,
text: 'Not your shopping cart, unfortunately.'
})
// Remove from cart
const cart = await bag.updateTrade({
tradeId: app.metadata[props.context.userId].id,
identityId: process.env.ME,
remove: [{ id: instance.id, quantity: instance.quantity }]
})
// Update gp
await bag.updateTrade({
tradeId: app.metadata[props.context.userId].id,
identityId: props.context.userId,
remove: [
{
id: cart.initiatorTrades[0].instanceId, // gp tradeInstance id
quantity: canSell[instance.name] * instance.quantity
}
]
})
return await props.client.chat.update({
...thread,
blocks: await showStore(slack, thread)
})
})
Hopefully this is pretty self-explanatory by now :)
And that's it! There are plenty of ways to extend this! Things you can do:
In the meantime you can mention @orpheus-store
in the Slack anywhere and buy some fancy pants or cakes. Or for a bigger selection of items, you could mention @general-store
in #general-store.
The source code is here.