💰 Bag

Quickstart

What's this? Getting started

Building a store bot

(LEGACY) Building a store bot

Codebase

(LEGACY) Building a store bot

This guide uses the old trade API. It no longer works.

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:

  • Node.js
  • NPM
  • A test Slack account: all the code here has to be run by a second Slack user because you can't trade with yourself, ya know?

Setup

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.

Create a bag app

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>

Set up our Slack app

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 write some code!

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.

Listing a catalog

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?

Add to cart

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.

Processing the form

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.

Making the cart show up

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:

Checking out

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.

Cancel checkout

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?'
  })
})

Removing from the cart

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 :)

Extending

And that's it! There are plenty of ways to extend this! Things you can do:

  • Charge some tax hehe
  • Add discount/coupon functionality for friends

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.