line

Where to lunch with your teammates on Slack

The workflow Where To Lunch is a friendly slack bot to recommend restaurants and organize a daily vote for nearby lunch options. The workflow is scheduled every day when you want and will suggest restaurants thanks to Google Places. Airtable is used in this example as a database. This is a fun project that you can quickly deploy on Heroku and use for your team or make modifications to the workflow to suit your whims.

The workflow Where To Lunch is a friendly slack bot to recommend restaurants and organize a daily vote for nearby lunch options.

  • Automatically post a list of nearby restaurants every day
  • Invite coworkers to vote on their preference
  • Change the vote time and window to suite your schedule
  • Go to Lunch!

The bot uses two Zenaton workflows:

  • configureWorkflow.js to set the current location and fetch places
  • proposePlaceAndCollectVotes.js to manage the vote process

Workflow Logic

Workflow to set locations and fetch places

The slackbot is configured by using slack commands and when the /wheretolunch configure [your address] is entered, the configureWorkflow.js is launched.

It extract restaurants around the address with a maximum radius of 1500 meters and a price not higher than €€€ using the Google Places API. The results are saved in Airtable.

// The Zenaton engine orchestrates all tasks of this workflow and related logic via the Zenaton agent.
// Every step is executed at the right moment on your servers and monitored on Zenaton dashboard.

"use strict";
const { workflow } = require("zenaton");

module.exports = workflow("configureWorkflow", {
  *handle(
    teamId,
    channelId,
    address,
    radius = 1500,
    maxPrice = 3,
    numberOfPlaces = 5,
    reset = true
  ) {
    // Init
    this.teamId = teamId;
    this.channelId = channelId;
    this.address = address;
    this.radius = radius;
    this.maxprice = maxPrice;
    this.numberOfPlaces = numberOfPlaces;
    this.places = [];

    // List team places
    this.places = yield this.run.task(
      "listTeamPlaces",
      process.env.AIRTABLE_PLACES_TABLE,
      this.teamId,
      this.channelId
    );

    // Delete places if reset = true
    if (reset && this.places.length > 0) {
      const recordIds = this.places.map(place => place.airtable_id);
      yield this.run.task(
        "deleteRecords",
        process.env.AIRTABLE_PLACES_TABLE,
        recordIds
      );
      this.places = [];
    }

    const placesToFetch = this.numberOfPlaces - this.places.length;
    if (placesToFetch > 0) {
      // Search missing places from Google Place API
      const newPlaces = yield this.run.task(
        "getPlacesFromGoogleApi",
        this.address,
        this.radius,
        this.maxprice,
        this.numberOfPlaces
      );

      // Save new places to Airtable
      yield this.run.task(
        "createAirtableRecords",
        process.env.AIRTABLE_PLACES_TABLE,
        this.teamId,
        this.channelId,
        newPlaces
      );
      this.places.push(newPlaces);
    }
  },
  id() {
    return this.teamId;
  }
});

Workflow to manage the vote process

This workflow is triggered when the users would have to vote. The slack command is /wheretolunch vote_now. It gets the result from Airtable and display the list of restaurant suggested. It counts users votes and get the one that have the highest number of votes to send a message in the channel announcing the winner.

// The Zenaton engine orchestrates all tasks of this workflow and related logic via the Zenaton agent.
// Every step is executed at the right moment on your servers and monitored on Zenaton dashboard.

"use strict";
const { workflow, duration } = require("zenaton");

const WEEK_END = [0, 6];
const NUMBER_EMOJIS = [":one:", ":two:", ":three:", ":four:", ":five:"];

module.exports = workflow("proposePlaceAndCollectVotes", {
  *handle(teamId, channelId) {
    const { dayOfWeek } = this.run.task("getCurrentDate");
    this.teamId = teamId;
    this.channelId = channelId;
    this.places = [];
    this.users = [];
    this.winner = null;
    this.voteDuration = 15;
    this.enabledDuringWeekend = false;

    if (this.enabledDuringWeekend || !WEEK_END.indexOf(dayOfWeek) > -1) {
      // List team places
      const places = yield this.run
        .task(
          "listTeamPlaces",
          process.env.AIRTABLE_PLACES_TABLE,
          this.teamId,
          this.channelId
        );

      // Set vote to 0 for each places
      this.places = places.map(place => {
        return { ...place, vote: 0 };
      });

      // List all channel members
      const membersRes = yield this.run.task("slack", "conversations.members", {
        channel: this.channelId
      });
      this.users = membersRes.members;

      // Post the poll on the Slack channel
      this.pollMessage = yield this.run.task("slack", "chat.postMessage", {
        channel: this.channelId,
        text: buildPollText(this.places)
      });

      // Send an ephemeral vote message to all channel members
      const sendTasks = this.users.map(userId => [
        "slack",
        "chat.postEphemeral",
        {
          channel: this.channelId,
          user: userId,
          attachments: buildVoteAttachments(places.length)
        }
      ]);
      yield this.run.task(...sendTasks);

      // Awaits the end of the vote
      yield this.wait.for(duration.minutes(this.voteDuration));

      // Get the winner place
      this.winner = getWinner(this.places);

      // Post the winner place in the Slack channel
      yield this.run.task("slack", "chat.postMessage", {
        channel: this.channelId,
        text: `Today you will lunch at ${placeLink(this.winner)}!`
      });
    }
  },
  id() {
    return "votes:" + this.teamId;
  },
  *onEvent(name, data) {
    if (name === "VoteEvent" && this.winner == null) {
      // Take the vote into account
      const placeNumber = parseInt(data.payload.actions[0].value);
      this.places[placeNumber - 1].vote += 1;

      // Update the poll message
      yield this.run.task("slack", "chat.update", {
        channel: this.channelId,
        text: buildPollText(this.places),
        ts: this.pollMessage.ts
      });
    }
  }
});

function placeLink(place) {
  const gMapInfo = JSON.parse(place.payload);
  return `//www.google.com/maps/search/?api=1&query=${gMapInfo.geometry.location.lat},${gMapInfo.geometry.location.lng}&query_place_id=${place.place_id}|${place.name}>`;
}

function getWinner(places) {
  return places.sort((place1, place2) => place2.vote - place1.vote)[0];
}

function buildPollText(places) {
  return places.reduce((acc, place, index) => {
    return (
      acc + `${NUMBER_EMOJIS[index]} ${placeLink(place)} \`${place.vote}\`\n\n`
    );
  }, "");
}

function buildVoteAttachments(optionCount) {
  const actions = [...Array(optionCount).keys()].map(optionIndex => {
    return {
      name: "place",
      type: "button",
      text: NUMBER_EMOJIS[optionIndex],
      value: optionIndex + 1
    };
  });

  return [
    {
      text: "Choose where you want to lunch.",
      callback_id: "vote_button",
      actions: actions
    }
  ];
}

Schedule the workflow

Zenaton provides a scheduler. Therefore, it's easy to schedule the vote for the restaurants every day at special time. You have access to your tasks or workflows schedules in your dashboard. You can pause, resume or kill them. More information are in the documentation here.

It's also possible to schedule the vote directly from Slack using the command /wheretolunch schedule_vote 15 11 * * MON-FRI using cron expressions.

Deploy and install your own instance

To deploy your own instance, you will need a:

  • Zenaton account (the workflow engine)
  • Airtable account (the database)
  • Google Places API key (to find places near your location)
  • Slack application

Then, you can follow the steps described on the readme in Github. The code can be quickly deployed on Heroku or you can use another cloud provider (see going to production).