Create a social app to log runs with running buddies

Advanced

In this tutorial, you'll learn how to create a social app to log runs with your virtual running buddies using our next-gen platform.

We'll pace you along the path to design a datastore, craft a custom type, tailor triggers, wire workflows, and form functions.

Now that you're familiar with the course map, let's head to the start line!

Step 1Complete your warm-up

Each Slack app built using the CLI begins with the same steps. Make sure you have everything you need before you show up at the start line.

  • Create a new project

    After you've installed the command-line interface, you have two ways you can get started:

    Start from scratch

    You can create a fresh new project:

    slack create deno-virtual-running-buddies
    

    Use a template

    Or, you can clone the sample project from GitHub:

    slack create my-run-app -t slack-samples/deno-virtual-running-buddies
    

    Get to the start line

    Once you have your new project ready to go, change into your project directory:

    cd my-run-app
    
Step complete!

Step 2Map a course for your app

Determining the definitions and manifest of your app allows you to create a course map of where you want to go. Open your text editor (we recommend VSCode with the Deno plugin) and point to the directory you created with the slack command.

  • Make your manifest

    Import and add the following definitions to your app's manifest:

    import { Manifest } from "deno-slack-sdk/mod.ts";
    import RunningDatastore from "./datastores/run_data.ts";
    import LogRunWorkflow from "./workflows/log_run_workflow.ts";
    import DisplayLeaderboardWorkflow from "./workflows/display_leaderboard_workflow.ts";
    import { RunnerStatsType } from "./types/runner_stats.ts";
    
    export default Manifest({
      name: "my-run-app",
      description: "Log runs with virtual running buddies!",
      icon: "assets/icon.png",
      workflows: [LogRunWorkflow, DisplayLeaderboardWorkflow],
      outgoingDomains: [],
      datastores: [RunningDatastore],
      types: [RunnerStatsType],
      botScopes: [
        "commands",
        "chat:write",
        "chat:write.public",
        "datastore:read",
        "datastore:write",
        "channels:read",
        "triggers:write",
      ],
    });
    
    

    You'll notice a lot of stuff we haven't talked about yet, but not to worry! We'll cover everything in the following steps.

Step complete!

Step 3Define a datastore

Design a datastore so that you have a place to store your team's runs.

  • Design a datastore

    We need a way to store all the information that our running buddies log, including their user ID (runner), how long they ran (distance), and the date they ran (rundate). Enter datastores!

    Datastores have three main properties:

    • name: to identify your datastore.
    • primary_key: the attribute to be used as the datastore's primary key (ensure this is an actual attribute that you have defined), which we'll use for querying information later. For more information, refer to querying the datastore.
    • attributes: to scaffold your datastore's columns.

    For what we need, the following datastore will do the trick. Let's create a datastores folder and add a file called run_data.ts with our datastore definition:

    import { DefineDatastore, Schema } from "deno-slack-sdk/mod.ts";
    
    export const RUN_DATASTORE = "running_datastore";
    
    const RunningDatastore = DefineDatastore({
      name: RUN_DATASTORE,
      primary_key: "id",
      attributes: {
        id: {
          type: Schema.types.string,
        },
        runner: {
          type: Schema.slack.types.user_id,
        },
        distance: {
          type: Schema.types.number,
        },
        rundate: {
          type: Schema.slack.types.date,
        },
      },
    });
    
    export default RunningDatastore;
    
    

    We already did this earlier when we defined our manifest — but if you hadn't yet, you would need to import your datastore within the manifest file:

    import RunningDatastore from "./datastores/run_data.ts";

    And then, register it:

    datastores: [RunningDatastore],

    Additionally, for any datastore, you'll also need to add the following bot scopes to your manifest:

    • datastore:read
    • datastore:write
Step complete!

Step 4Craft a custom type

Create a custom type for your runners' recent runs.

  • Craft a custom type

    The next thing we'll do is define a custom type for our runners' recent runs. Since we're going to be passing this information to our datastore, workflows, and functions, having our own custom reusable type will make our lives a little easier. For more information about custom types and how to define them, refer to custom types.

    We'll create a new types folder with a file called runner_stats.ts with our custom type definition:

    import { DefineType, Schema } from "deno-slack-sdk/mod.ts";
    
    export const RunnerStatsType = DefineType({
      title: "Runner Stats",
      description: "Information about the recent runs for a runner",
      name: "runner_stats",
      type: Schema.types.object,
      properties: {
        runner: { type: Schema.slack.types.user_id },
        weekly_distance: { type: Schema.types.number },
        total_distance: { type: Schema.types.number },
      },
      required: ["runner", "weekly_distance", "total_distance"],
    });
    
    

    Just like with our datastore, we'll also verify that we imported our custom type into our manifest earlier:

    import { RunnerStatsType } from "./types/runner_stats.ts";

    And then, make sure it's registered:

    types: [RunnerStatsType],

Step complete!

Step 5Wire your workflows

Outline the steps of your workflows.

  • Wire workflows

    Next, let's define our workflows — we'll have two of them. Don't worry about functions yet; we'll get to those next. For now, you can just import and call them as shown in our workflows.

    For our first workflow, we need three steps:

    1. Allow a runner on our team to log their run information. The built-in function OpenForm is used to collect data.
    2. Save that information to the datastore we defined earlier.
    3. Post a message of encouragement to the runner. Every runner loves support from the crowd!

    Let's create a workflows folder and add a file called log_run_workflow.ts with the following workflow definition:

    import { DefineWorkflow, Schema } from "deno-slack-sdk/mod.ts";
    import { LogRunFunction } from "../functions/log_run.ts";
    
    const LogRunWorkflow = DefineWorkflow({
      callback_id: "log_run_workflow",
      title: "Log a run",
      description: "Collect and store info about a recent run",
      input_parameters: {
        properties: {
          interactivity: { type: Schema.slack.types.interactivity },
          channel: { type: Schema.slack.types.channel_id },
          user_id: { type: Schema.slack.types.user_id },
        },
        required: ["interactivity", "channel", "user_id"],
      },
    });
    
    // Step 1: Collect run information with a form
    const inputForm = LogRunWorkflow.addStep(
      Schema.slack.functions.OpenForm,
      {
        title: "Log your run",
        interactivity: LogRunWorkflow.inputs.interactivity,
        submit_label: "Submit run",
        fields: {
          elements: [{
            name: "runner",
            title: "Runner",
            type: Schema.slack.types.user_id,
            default: LogRunWorkflow.inputs.user_id,
          }, {
            name: "distance",
            title: "Distance (in miles)",
            type: Schema.types.number,
            minimum: 0,
          }, {
            name: "rundate",
            title: "Run date",
            type: Schema.slack.types.date,
            default: new Date().toLocaleDateString("en-CA"),
          }, {
            name: "channel",
            title: "Channel to send entry to",
            type: Schema.slack.types.channel_id,
            default: LogRunWorkflow.inputs.channel,
          }],
          required: ["channel", "runner", "distance", "rundate"],
        },
      },
    );
    
    // Step 2: Save run info to the datastore
    LogRunWorkflow.addStep(LogRunFunction, {
      runner: inputForm.outputs.fields.runner,
      distance: inputForm.outputs.fields.distance,
      rundate: inputForm.outputs.fields.rundate,
    });
    
    // Step 3: Post a message about the run
    LogRunWorkflow.addStep(Schema.slack.functions.SendMessage, {
      channel_id: inputForm.outputs.fields.channel,
      message:
        `:athletic_shoe: <@${inputForm.outputs.fields.runner}> submitted ${inputForm.outputs.fields.distance} mile(s) on ${inputForm.outputs.fields.rundate}. Keep up the great work!`,
    });
    
    export default LogRunWorkflow;
    

    For our second workflow, we need to generate our leaderboard. To do this, our workflow contains the following steps:

    1. Gather our team's runs.
    2. Gather an individual's runs.
    3. Format our leaderboard.
    4. Post the leaderboard to our channel.
    import { DefineWorkflow, Schema } from "deno-slack-sdk/mod.ts";
    import { CollectTeamStatsFunction } from "../functions/collect_team_stats.ts";
    import { CollectRunnerStatsFunction } from "../functions/collect_runner_stats.ts";
    import { FormatLeaderboardFunction } from "../functions/format_leaderboard.ts";
    
    const DisplayLeaderboardWorkflow = DefineWorkflow({
      callback_id: "display_leaderboard_workflow",
      title: "Display the leaderboard",
      description:
        "Show team statistics and highlight the top runners from the past week",
      input_parameters: {
        properties: {
          channel: { type: Schema.slack.types.channel_id },
          interactivity: { type: Schema.slack.types.interactivity },
        },
        required: ["channel", "interactivity"],
      },
    });
    
    // Step 1: Gather team stats from the past week
    const teamStats = DisplayLeaderboardWorkflow.addStep(
      CollectTeamStatsFunction,
      {},
    );
    
    // Step 2: Collect individual runner stats
    const runnerStats = DisplayLeaderboardWorkflow.addStep(
      CollectRunnerStatsFunction,
      {},
    );
    
    // Step 3: Format the leaderboard message
    const leaderboard = DisplayLeaderboardWorkflow.addStep(
      FormatLeaderboardFunction,
      {
        team_distance: teamStats.outputs.weekly_distance,
        percent_change: teamStats.outputs.percent_change,
        runner_stats: runnerStats.outputs.runner_stats,
      },
    );
    
    // Step 4: Post the leaderboard message to channel
    DisplayLeaderboardWorkflow.addStep(Schema.slack.functions.SendMessage, {
      channel_id: DisplayLeaderboardWorkflow.inputs.channel,
      message:
        `${leaderboard.outputs.teamStatsFormatted}\n\n${leaderboard.outputs.runnerStatsFormatted}`,
    });
    
    export default DisplayLeaderboardWorkflow;
    

    Our workflows also need to be imported into our manifest, so let's just double-check the following lines are there: import LogRunWorkflow from "./workflows/log_run_workflow.ts"; import DisplayLeaderboardWorkflow from "./workflows/display_leaderboard_workflow.ts";

    And that they are registered as well:

    workflows: [LogRunWorkflow, DisplayLeaderboardWorkflow],

Step complete!

Step 6Form your functions

Define the inputs and outputs of your functions.

  • Form your functions

    Following fast, functions: we'll fashion four.

    The first function will store our collected run data in our datastore (so don't forget to import it first):

    import { DefineFunction, Schema, SlackFunction } from "deno-slack-sdk/mod.ts";
    import { RUN_DATASTORE } from "../datastores/run_data.ts";
    
    export const LogRunFunction = DefineFunction({
      callback_id: "log_run",
      title: "Log a run",
      description: "Record a run in the datastore",
      source_file: "functions/log_run.ts",
      input_parameters: {
        properties: {
          runner: {
            type: Schema.slack.types.user_id,
            description: "Runner",
          },
          distance: {
            type: Schema.types.number,
            description: "Distance",
          },
          rundate: {
            type: Schema.slack.types.date,
            description: "Run date",
          },
        },
        required: ["runner", "distance", "rundate"],
      },
      output_parameters: {
        properties: {},
        required: [],
      },
    });
    
    export default SlackFunction(LogRunFunction, async ({ inputs, client }) => {
      const { distance, rundate, runner } = inputs;
      const uuid = crypto.randomUUID();
    
      const putResponse = await client.apps.datastore.put({
        datastore: RUN_DATASTORE,
        item: {
          id: uuid,
          runner: runner,
          distance: distance,
          rundate: rundate,
        },
      });
    
      if (!putResponse.ok) {
        return { error: `Failed to store run: ${putResponse.error}` };
      }
      return { outputs: {} };
    });
    
    

    For more information about how data is stored and successful vs. unsuccessful payloads, refer to creating or updating an item.


    Our second function calculates weekly and all-time total distance statistics for an individual runner.

    We'll query the datastore to get a runner's logged run data, calculate some statistics for that runner, and then return an array with all of their information:

    import { DefineFunction, Schema, SlackFunction } from "deno-slack-sdk/mod.ts";
    import RunningDatastore, { RUN_DATASTORE } from "../datastores/run_data.ts";
    import { RunnerStatsType } from "../types/runner_stats.ts";
    
    export const CollectRunnerStatsFunction = DefineFunction({
      callback_id: "collect_runner_stats",
      title: "Collect runner stats",
      description: "Gather statistics of past runs for all runners",
      source_file: "functions/collect_runner_stats.ts",
      input_parameters: {
        properties: {},
        required: [],
      },
      output_parameters: {
        properties: {
          runner_stats: {
            type: Schema.types.array,
            items: { type: RunnerStatsType },
            description: "Weekly and all-time total distances for runners",
          },
        },
        required: ["runner_stats"],
      },
    });
    
    export default SlackFunction(CollectRunnerStatsFunction, async ({ client }) => {
      // Query the datastore for all the data we collected
      const runs = await client.apps.datastore.query<
        typeof RunningDatastore.definition
      >({ datastore: RUN_DATASTORE });
    
      if (!runs.ok) {
        return { error: `Failed to retrieve past runs: ${runs.error}` };
      }
    
      const runners = new Map<typeof Schema.slack.types.user_id, {
        runner: typeof Schema.slack.types.user_id;
        total_distance: number;
        weekly_distance: number;
      }>();
    
      const startOfLastWeek = new Date();
      startOfLastWeek.setDate(startOfLastWeek.getDate() - 6);
    
      // Add run statistics to the associated runner
      runs.items.forEach((run) => {
        const isRecentRun = run.rundate >=
          startOfLastWeek.toLocaleDateString("en-CA", { timeZone: "UTC" });
    
        // Find existing runner record or create new one
        const runner = runners.get(run.runner) ||
          { runner: run.runner, total_distance: 0, weekly_distance: 0 };
    
        // Add run distance to the runner's totals
        runners.set(run.runner, {
          runner: run.runner,
          total_distance: runner.total_distance + run.distance,
          weekly_distance: runner.weekly_distance + (isRecentRun && run.distance),
        });
      });
    
      // Return an array with runner stats
      return {
        outputs: { runner_stats: [...runners.entries()].map((r) => r[1]) },
      };
    });
    
    

    For more information about how data is retrieved and successful vs. unsuccessful payloads, refer to retrieving a single item and querying the datstore.


    Our third function calculates the weekly and all-time total distance for the whole team, as well as the percentage difference between this week's runs from the previous week's runs.

    Similar to the query for individual runners, we'll query the datastore to get the team's logged run data, calculate statistics for the team, and then return all of this information:

    import { DefineFunction, Schema, SlackFunction } from "deno-slack-sdk/mod.ts";
    import { SlackAPIClient } from "deno-slack-api/types.ts";
    import RunningDatastore, { RUN_DATASTORE } from "../datastores/run_data.ts";
    
    export const CollectTeamStatsFunction = DefineFunction({
      callback_id: "collect_team_stats",
      title: "Collect team stats",
      description: "Gather and compare run data from the last week",
      source_file: "functions/collect_team_stats.ts",
      input_parameters: {
        properties: {},
        required: [],
      },
      output_parameters: {
        properties: {
          weekly_distance: {
            type: Schema.types.number,
            description: "Total number of miles ran last week",
          },
          percent_change: {
            type: Schema.types.number,
            description: "Percent change of miles ran compared to the prior week",
          },
        },
        required: ["weekly_distance", "percent_change"],
      },
    });
    
    export default SlackFunction(CollectTeamStatsFunction, async ({ client }) => {
      const today = new Date();
    
      // Collect runs from the past week (days 0-6)
      const lastWeekStartDate = new Date(new Date().setDate(today.getDate() - 6));
      const lastWeekDistance = await distanceInWeek(client, lastWeekStartDate);
      if (lastWeekDistance.error) {
        return { error: lastWeekDistance.error };
      }
    
      // Collect runs from the prior week (days 7-13)
      const priorWeekStartDate = new Date(new Date().setDate(today.getDate() - 13));
      const priorWeekDistance = await distanceInWeek(client, priorWeekStartDate);
      if (priorWeekDistance.error) {
        return { error: priorWeekDistance.error };
      }
    
      // Calculate percent difference between totals of last week and the prior week
      const weeklyDiff = lastWeekDistance.total - priorWeekDistance.total;
      let percentageDiff = 0;
      if (priorWeekDistance.total != 0) {
        percentageDiff = 100 * weeklyDiff / priorWeekDistance.total;
      }
    
      return {
        outputs: {
          weekly_distance: Number(lastWeekDistance.total.toFixed(2)),
          percent_change: Number(percentageDiff.toFixed(2)),
        },
      };
    });
    
    // Sum all logged runs in the seven days following startDate
    async function distanceInWeek(
      client: SlackAPIClient,
      startDate: Date,
    ): Promise<{ total: number; error?: string }> {
      const endDate = new Date(startDate);
      endDate.setDate(endDate.getDate() + 6);
    
      const runs = await client.apps.datastore.query<
        typeof RunningDatastore.definition
      >({
        datastore: RUN_DATASTORE,
        expression: "#date BETWEEN :start_date AND :end_date",
        expression_attributes: { "#date": "rundate" },
        expression_values: {
          ":start_date": startDate.toLocaleDateString("en-CA", { timeZone: "UTC" }),
          ":end_date": endDate.toLocaleDateString("en-CA", { timeZone: "UTC" }),
        },
      });
    
      if (!runs.ok) {
        return { total: 0, error: `Failed to retrieve past runs: ${runs.error}` };
      }
    
      const total = runs.items.reduce((sum, entry) => (sum + entry.distance), 0);
      return { total };
    }
    

    Our final function generates our ordered leaderboard, as well as a formatted message with all of our queried data and calculated statistics:

    import { DefineFunction, Schema, SlackFunction } from "deno-slack-sdk/mod.ts";
    import { RunnerStatsType } from "../types/runner_stats.ts";
    
    export const FormatLeaderboardFunction = DefineFunction({
      callback_id: "format_leaderboard",
      title: "Format leaderboard message",
      description: "Format team and runner stats for a sharable message",
      source_file: "functions/format_leaderboard.ts",
      input_parameters: {
        properties: {
          team_distance: {
            type: Schema.types.number,
            description: "Total number of miles ran last week for the team",
          },
          percent_change: {
            type: Schema.types.number,
            description:
              "Percent change of miles ran compared to the prior week for the team",
          },
          runner_stats: {
            type: Schema.types.array,
            items: { type: RunnerStatsType },
            description: "Weekly and all-time total distances for runners",
          },
        },
        required: ["team_distance", "percent_change", "runner_stats"],
      },
      output_parameters: {
        properties: {
          teamStatsFormatted: {
            type: Schema.types.string,
            description: "A formatted message with team stats",
          },
          runnerStatsFormatted: {
            type: Schema.types.string,
            description: "An ordered leaderboard of runner stats",
          },
        },
        required: ["teamStatsFormatted", "runnerStatsFormatted"],
      },
    });
    
    export default SlackFunction(FormatLeaderboardFunction, ({ inputs }) => {
      const teamStatsFormatted =
        `Your team ran *${inputs.team_distance} miles* this past week: a ${inputs.percent_change}% difference from the prior week.`;
    
      const runnerStatsFormatted = inputs.runner_stats.sort((a, b) =>
        b.weekly_distance - a.weekly_distance
      ).map((runner) =>
        ` - <@${runner.runner}> ran ${runner.weekly_distance} miles last week (${runner.total_distance} total)`
      ).join("\n");
    
      return {
        outputs: { teamStatsFormatted, runnerStatsFormatted },
      };
    });
    
Step complete!

Step 7Tailor your triggers

Create some triggers to get things moving.

  • Tailor your triggers

    Triggers invoke workflows. There are four types of available triggers, but we'll just be using two of them: link triggers and scheduled triggers. For this app, we'll need three triggers, two of which will be link triggers. This means that they require a user to manually trigger them.

    First, we'll create a triggers folder and define a link trigger for collecting our team's runs called log_run_trigger.ts:

    import { Trigger } from "deno-slack-api/types.ts";
    import LogRunWorkflow from "../workflows/log_run_workflow.ts";
    
    const LogRunTrigger: Trigger<typeof LogRunWorkflow.definition> = {
      type: "shortcut",
      name: "Log a run",
      description: "Save the details of a recent run",
      workflow: `#/workflows/${LogRunWorkflow.definition.callback_id}`,
      inputs: {
        interactivity: {
          value: "{{data.interactivity}}",
        },
        channel: {
          value: "{{data.channel_id}}",
        },
        user_id: {
          value: "{{data.user_id}}",
        },
      },
    };
    
    export default LogRunTrigger;
    
    

    Run the trigger create command in terminal:

    slack trigger create --trigger-def triggers/log_run_trigger.ts
    

    After executing this command, select your app and workspace. Once completed, you'll be given a link called "Shortcut URL." This is your link trigger for this workflow on this workspace.

    Save that URL for when you start testing, since that's how you'll invoke this particular trigger. You can also use the slack triggers -info command and select your workspace to grab that URL again later, or click the / icon within Slack to open the Run workflow menu and select your trigger.


    Second, we'll need a trigger to display our leaderboard. Create a second link trigger in that same triggers folder called display_leaderboard_trigger.ts and define it as follows:

    import { Trigger } from "deno-slack-api/types.ts";
    import DisplayLeaderboardWorkflow from "../workflows/display_leaderboard_workflow.ts";
    
    const DisplayLeaderboardTrigger: Trigger<
      typeof DisplayLeaderboardWorkflow.definition
    > = {
      type: "shortcut",
      name: "Display the leaderboard",
      description: "Show stats for the team and individual runners",
      workflow: `#/workflows/${DisplayLeaderboardWorkflow.definition.callback_id}`,
      inputs: {
        interactivity: {
          value: "{{data.interactivity}}",
        },
        channel: {
          value: "{{data.channel_id}}",
        },
      },
    };
    
    export default DisplayLeaderboardTrigger;
    
    

    Run the trigger create command in the terminal again and save the Shortcut URL for this trigger as well:

    slack trigger create --trigger-def triggers/display_leaderboard_trigger.ts
    

    Finally, we'll create a scheduled trigger in our triggers folder to post a message to a channel with our stats on a weekly basis. We'll call this one display_weekly_stats.ts:

    import { Trigger } from "deno-slack-api/types.ts";
    import DisplayLeaderboardWorkflow from "../workflows/display_leaderboard_workflow.ts";
    
    const DisplayWeeklyStats: Trigger<
      typeof DisplayLeaderboardWorkflow.definition
    > = {
      type: "scheduled",
      name: "Display weekly stats",
      description: "Display weekly running stats on a schedule",
      workflow: `#/workflows/${DisplayLeaderboardWorkflow.definition.callback_id}`,
      inputs: {
        interactivity: {
          value: "{{data.interactivity}}",
        },
        channel: {
          value: "{{data.channel_id}}",
        },
      },
      schedule: {
        start_time: new Date(new Date().getTime() + 60000).toISOString(),
        timezone: "EDT",
        frequency: {
          type: "weekly",
          on_days: ["Thursday"],
          repeats_every: 1,
        },
      },
    };
    
    export default DisplayWeeklyStats;
    
    

    Since this is a scheduled trigger, we won't have a Shortcut URL for this one since there's nothing to invoke manually. Use the following command to create the scheduled trigger:

    slack trigger create --trigger-def triggers/display_weekly_stats.ts

    Make sure that the app has the triggers:write scope added to the manifest!

Step complete!

Step 8Cross the finish line

Run your code and try it out!

  • Run your code to test and tweak.

    You're almost to the finish line! Let's use development mode to run this workflow in Slack directly from the machine you're reading this from now:

    slack run
    

    After you've chosen your app and assigned it to your workspace, you can switch over to the app in Slack and try it out. Use the link triggers you created previously; when you paste the Shortcut URLs into the box and post them as messages, they'll unfurl and give you buttons for invoking our workflows.

    Here is an example of the message displayed after logging a run:

    Log a run

    Here is an example of a message displayed after generating the leaderboard:

    Display leaderboard

    Nice run!

    Now that you've stepped through the platform and finished the race, we'd love to hear what you think.


Step complete!

Was this page helpful?