Build a Slackbot·续1

token=gIkuvaNzQIHg97ATvDxqgjtO
&team_id=T0001
&team_domain=example
&enterprise_id=E0001
&enterprise_name=Globular%20Construct%20Inc
&channel_id=C2147483705
&channel_name=test
&user_id=U2147483697
&user_name=Steve
&command=/issue
&text=cloudflare/wrangler#1
&response_url=https://hooks.slack.com/commands/1234/5678
&trigger_id=13345224609.738474920.8088930838d88f008e0

Given this payload body, you need to parse it, and get the value of the text key. With that text, for example, cloudflare/wrangler#1, you can parse that string into known piece of data (owner, repo, and issue_number), and use it to make a request to GitHub’s API, to retrieve the issue data.

With Slack slash commands, you can respond to a slash command by returning structured data as the response to the incoming slash command. In this case, you should use the response from GitHub’s API to present a formatted version of the GitHub issue, including pieces of data like the title of the issue, who created it, and the date it was created. Slack’s new Block Kit ↗︎ framework will allow you to return a detailed message response, by constructing text and image blocks with the data from GitHub’s API.

Parsing slash commands

To begin, the lookup route should parse the messages coming from Slack. As previously mentioned, the Slack API sends an HTTP POST in URL Encoded format. You can get the variable text by parsing it with c.req.json().

import { Hono } from "hono";

const app = new Hono();

app.post("/", async (c) => {
    const { text } = await c.req.parseBody();
    if (typeof text !== "string") {
        return c.notFound();
    }
});

export default app;

Given a text variable, that contains text like cloudflare/wrangler#1, you should parse that text, and get the individual parts from it for use with GitHub’s API: owner, repo, and issue_number.

To do this, create a new file in your application, at src/utils/github.ts. This file will contain a number of “utility” functions for working with GitHub’s API. The first of these will be a string parser, called parseGhIssueString:

const ghIssueRegex =
    /(?<owner>[\w.-]*)\/(?<repo>[\w.-]*)\#(?<issue_number>\d*)/;

export const parseGhIssueString = (text: string) => {
    const match = text.match(ghIssueRegex);
    return match ? (match.groups ?? {}) : {};
};

parseGhIssueString takes in a text input, matches it against ghIssueRegex, and if a match is found, returns the groups object from that match, making use of the owner, repo, and issue_number capture groups defined in the regex. By exporting this function from src/utils/github.ts, you can make use of it back in src/handlers/lookup.ts:

import { Hono } from "hono";
import { parseGhIssueString } from "../utils/github";

const app = new Hono();

app.post("/", async (c) => {
    const { text } = await c.req.parseBody();
    if (typeof text !== "string") {
        return c.notFound();
    }

const { owner, repo, issue_number } = parseGhIssueString(text);
});

export default app;

Making requests to GitHub’s API

With this data, you can make your first API lookup to GitHub. Again, make a new function in src/utils/github.ts, to make a fetch request to the GitHub API for the issue data:

const ghIssueRegex =
    /(?<owner>[\w.-]*)\/(?<repo>[\w.-]*)\#(?<issue_number>\d*)/;

export const parseGhIssueString = (text: string) => {
    const match = text.match(ghIssueRegex);
    return match ? (match.groups ?? {}) : {};
};

export const fetchGithubIssue = (
    owner: string,
    repo: string,
    issue_number: string,
) => {
    const url = `https://api.github.com/repos/${owner}/${repo}/issues/${issue_number}`;
    const headers = { "User-Agent": "simple-worker-slack-bot" };
    return fetch(url, { headers });
};

Back in src/handlers/lookup.ts, use fetchGitHubIssue to make a request to GitHub’s API, and parse the response:

import { Hono } from "hono";
import { fetchGithubIssue, parseGhIssueString } from "../utils/github";
import { Issue } from "../types";

const app = new Hono();

app.post("/", async (c) => {
    const { text } = await c.req.parseBody();
    if (typeof text !== "string") {
        return c.notFound();
    }

const { owner, repo, issue_number } = parseGhIssueString(text);
    const response = await fetchGithubIssue(owner, repo, issue_number);
    const issue = await response.json<Issue>();
});

export default app;

Constructing a Slack message

After you have received a response back from GitHub’s API, the final step is to construct a Slack message with the issue data, and return it to the user. The final result will look something like this:

A successful Slack Message will have the components listed below

You can see four different pieces in the above screenshot:

  1. The first line (bolded) links to the issue, and shows the issue title
  2. The following lines (including code snippets) are the issue body
  3. The last line of text shows the issue status, the issue creator (with a link to the user’s GitHub profile), and the creation date for the issue
  4. The profile picture of the issue creator, on the right-hand side

The previously mentioned Block Kit ↗︎ framework will help take the issue data (in the structure lined out in GitHub’s REST API documentation ↗︎) and format it into something like the above screenshot.

Create another file, src/utils/slack.ts, to contain the function constructGhIssueSlackMessage, a function for taking issue data, and turning it into a collection of blocks. Blocks are JavaScript objects that Slack will use to format the message:

import { Issue } from "../types";

export const constructGhIssueSlackMessage = (
    issue: Issue,
    issue_string: string,
    prefix_text?: string,
) => {
    const issue_link = `<${issue.html_url}|${issue_string}>`;
    const user_link = `<${issue.user.html_url}|${issue.user.login}>`;
    const date = new Date(Date.parse(issue.created_at)).toLocaleDateString();

const text_lines = [
        prefix_text,
        `*${issue.title} - ${issue_link}*`,
        issue.body,
        `*${issue.state}* - Created by ${user_link} on ${date}`,
    ];
};

Slack messages accept a variant of Markdown, which supports bold text via asterisks (*bolded text*), and links in the format <https://yoururl.com|Display Text>.

Given that format, construct issue_link, which takes the html_url property from the GitHub API issue data (in format https://github.com/cloudflare/wrangler-legacy/issues/1), and the issue_string sent from the Slack slash command, and combines them into a clickable link in the Slack message.

user_link is similar, using issue.user.html_url (in the format https://github.com/signalnerve, a GitHub user) and the user’s GitHub username (issue.user.login), to construct a clickable link to the GitHub user.

Finally, parse issue.created_at, an ISO 8601 string, convert it into an instance of a JavaScript Date, and turn it into a formatted string, in the format MM/DD/YY.

With those variables in place, text_lines is an array of each line of text for the Slack message. The first line is the issue title and the issue link, the second is the issue body, and the final line is the issue state (for example, open or closed), the user link, and the creation date.

With the text constructed, you can finally construct your Slack message, returning an array of blocks for Slack’s Block Kit ↗︎. In this case, there is only have one block: a section ↗︎ block with Markdown text, and an accessory image of the user who created the issue. Return that single block inside of an array, to complete the constructGhIssueSlackMessage function:

import { Issue } from "../types";

export const constructGhIssueSlackMessage = (
    issue: Issue,
    issue_string: string,
    prefix_text?: string,
) => {
    const issue_link = `<${issue.html_url}|${issue_string}>`;
    const user_link = `<${issue.user.html_url}|${issue.user.login}>`;
    const date = new Date(Date.parse(issue.created_at)).toLocaleDateString();

const text_lines = [
        prefix_text,
        `*${issue.title} - ${issue_link}*`,
        issue.body,
        `*${issue.state}* - Created by ${user_link} on ${date}`,
    ];

return [
        {
            type: "section",
            text: {
                type: "mrkdwn",
                text: text_lines.join("\n"),
            },
            accessory: {
                type: "image",
                image_url: issue.user.avatar_url,
                alt_text: issue.user.login,
            },
        },
    ];
};

Finishing the lookup route

In src/handlers/lookup.ts, use constructGhIssueSlackMessage to construct blocks, and return them as a new response with c.json() when the slash command is called:

import { Hono } from "hono";
import { fetchGithubIssue, parseGhIssueString } from "../utils/github";
import { constructGhIssueSlackMessage } from "../utils/slack";
import { Issue } from "../types";

const app = new Hono();

app.post("/", async (c) => {
    const { text } = await c.req.parseBody();
    if (typeof text !== "string") {
        return c.notFound();
    }

const { owner, repo, issue_number } = parseGhIssueString(text);
    const response = await fetchGithubIssue(owner, repo, issue_number);
    const issue = await response.json<Issue>();
    const blocks = constructGhIssueSlackMessage(issue, text);

return c.json({
        blocks,
        response_type: "in_channel",
    });
});

export default app;

One additional parameter passed into the response is response_type. By default, responses to slash commands are ephemeral, meaning that they are only seen by the user who writes the slash command. Passing a response_type of in_channel, as seen above, will cause the response to appear for all users in the channel.

If you would like the messages to remain private, remove the response_type line. This will cause response_type to default to ephemeral.

Handling errors

The lookup route is almost complete, but there are a number of errors that can occur in the route, such as parsing the body from Slack, getting the issue from GitHub, or constructing the Slack message itself. Although Hono applications can handle errors without having to do anything, you can customize the response returned in the following way.

import { Hono } from "hono";
import { fetchGithubIssue, parseGhIssueString } from "../utils/github";
import { constructGhIssueSlackMessage } from "../utils/slack";
import { Issue } from "../types";

const app = new Hono();

app.post("/", async (c) => {
    const { text } = await c.req.parseBody();
    if (typeof text !== "string") {
        return c.notFound();
    }

const { owner, repo, issue_number } = parseGhIssueString(text);
    const response = await fetchGithubIssue(owner, repo, issue_number);
    const issue = await response.json<Issue>();
    const blocks = constructGhIssueSlackMessage(issue, text);

return c.json({
        blocks,
        response_type: "in_channel",
    });
});

app.onError((_e, c) => {
    return c.text(
        "Uh-oh! We couldn't find the issue you provided. " +
            "We can only find public issues in the following format: `owner/repo#issue_number`.",
    );
});

export default app;

Creating the webhook route

You are now halfway through implementing the routes for your Workers application. In implementing the next route, src/routes/webhook.ts, you will re-use a lot of the code that you have already written for the lookup route.

At the beginning of this tutorial, you configured a GitHub webhook to track any events related to issues in your repository. When an issue is opened, for example, the function corresponding to the path /webhook on your Workers application should take the data sent to it from GitHub, and post a new message in the configured Slack channel.

In src/routes/webhook.ts, define a blank Hono application. The difference from the lookup route is that the Bindings is passed as a generics for the new Hono(). This is necessary to give the appropriate TypeScript type to SLACK_WEBHOOK_URL which will be used later.